summrise-agent 1.2.509 → 1.2.511
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/bin/summrise.exe +0 -0
- package/package.json +11 -8
- package/summrise-agent.exe +0 -0
- package/summrise-desktop-electron/src/main.js +400 -354
- package/summrise-desktop-electron/src/preload.js +4 -1
- package/summrise-desktop-electron/src/summrise_shell_policy.js +1280 -0
- package/summrise-desktop-electron/src/summrise_shell_policy_bg.wasm +0 -0
- package/summrise-desktop-electron/src/summrise_url_policy.js +404 -0
- package/summrise-desktop-electron/src/summrise_url_policy_bg.wasm +0 -0
- package/summrise-launch.exe +0 -0
- package/bin/summrise.js +0 -3906
- package/summrise-desktop-electron/src/url-policy.js +0 -238
package/bin/summrise.js
DELETED
|
@@ -1,3906 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
-
if (k2 === undefined) k2 = k;
|
|
5
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
-
}
|
|
9
|
-
Object.defineProperty(o, k2, desc);
|
|
10
|
-
}) : (function(o, m, k, k2) {
|
|
11
|
-
if (k2 === undefined) k2 = k;
|
|
12
|
-
o[k2] = m[k];
|
|
13
|
-
}));
|
|
14
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
-
}) : function(o, v) {
|
|
17
|
-
o["default"] = v;
|
|
18
|
-
});
|
|
19
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
-
var ownKeys = function(o) {
|
|
21
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
-
var ar = [];
|
|
23
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
-
return ar;
|
|
25
|
-
};
|
|
26
|
-
return ownKeys(o);
|
|
27
|
-
};
|
|
28
|
-
return function (mod) {
|
|
29
|
-
if (mod && mod.__esModule) return mod;
|
|
30
|
-
var result = {};
|
|
31
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
-
__setModuleDefault(result, mod);
|
|
33
|
-
return result;
|
|
34
|
-
};
|
|
35
|
-
})();
|
|
36
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
-
exports.BOOT_TASKS = exports.DESKTOP_AUMID = exports.psq = void 0;
|
|
38
|
-
exports.psArgv = psArgv;
|
|
39
|
-
exports.lnkIdentity = lnkIdentity;
|
|
40
|
-
exports.deskShortcutRepairPs = deskShortcutRepairPs;
|
|
41
|
-
exports.startDesktopPs = startDesktopPs;
|
|
42
|
-
exports.ensureDesktopPs = ensureDesktopPs;
|
|
43
|
-
exports.desktopTaskPs = desktopTaskPs;
|
|
44
|
-
exports.desktopStartPs = desktopStartPs;
|
|
45
|
-
exports.parseAgentPort = parseAgentPort;
|
|
46
|
-
exports.parseDeviceToken = parseDeviceToken;
|
|
47
|
-
exports.parseTargetArg = parseTargetArg;
|
|
48
|
-
exports.asciiJson = asciiJson;
|
|
49
|
-
exports.deviceApi = deviceApi;
|
|
50
|
-
exports.fmtDuration = fmtDuration;
|
|
51
|
-
exports.targetLine = targetLine;
|
|
52
|
-
exports.monitorsJson = monitorsJson;
|
|
53
|
-
exports.probeLine = probeLine;
|
|
54
|
-
exports.agentPort = agentPort;
|
|
55
|
-
exports.firewallPs = firewallPs;
|
|
56
|
-
exports.bootTaskPs = bootTaskPs;
|
|
57
|
-
exports.migrateLayoutPs = migrateLayoutPs;
|
|
58
|
-
exports.uninstallRegBodyPs = uninstallRegBodyPs;
|
|
59
|
-
exports.uninstallVersionPs = uninstallVersionPs;
|
|
60
|
-
exports.autostartArgv = autostartArgv;
|
|
61
|
-
exports.playwrightProbePs = playwrightProbePs;
|
|
62
|
-
exports.busyIsFresh = busyIsFresh;
|
|
63
|
-
exports.latestCdnVersion = latestCdnVersion;
|
|
64
|
-
exports.latestNpmVersion = latestNpmVersion;
|
|
65
|
-
exports.newestOf = newestOf;
|
|
66
|
-
exports.latestReleaseVersion = latestReleaseVersion;
|
|
67
|
-
exports.cdnBase = cdnBase;
|
|
68
|
-
exports.componentUrl = componentUrl;
|
|
69
|
-
exports.componentFetchUrl = componentFetchUrl;
|
|
70
|
-
exports.componentKey = componentKey;
|
|
71
|
-
exports.componentPins = componentPins;
|
|
72
|
-
exports.sha256File = sha256File;
|
|
73
|
-
exports.resolveComponent = resolveComponent;
|
|
74
|
-
exports.ensureElectron = ensureElectron;
|
|
75
|
-
exports.statusReport = statusReport;
|
|
76
|
-
exports.updateWouldNotMove = updateWouldNotMove;
|
|
77
|
-
exports.isBehind = isBehind;
|
|
78
|
-
exports.behindBy = behindBy;
|
|
79
|
-
exports.updateReceiptPs = updateReceiptPs;
|
|
80
|
-
exports.updateBusyPath = updateBusyPath;
|
|
81
|
-
exports.busyMarkerPs = busyMarkerPs;
|
|
82
|
-
exports.awaitReleaseMarker = awaitReleaseMarker;
|
|
83
|
-
exports.releaseMarkerVerdict = releaseMarkerVerdict;
|
|
84
|
-
exports.boxedVersions = boxedVersions;
|
|
85
|
-
exports.writeBoxedVersions = writeBoxedVersions;
|
|
86
|
-
exports.writeReleaseMarker = writeReleaseMarker;
|
|
87
|
-
exports.rollbackVersionOk = rollbackVersionOk;
|
|
88
|
-
/**
|
|
89
|
-
* summrise CLI — DSH-style management for the Summrise Agent.
|
|
90
|
-
*
|
|
91
|
-
* The agent is a headless auto-start service; management lives here (CLI)
|
|
92
|
-
* and in the web panel (http://127.0.0.1:18080/panel/, desktop shortcut
|
|
93
|
-
* created by setup). The native tray was retired 2026-08-22.
|
|
94
|
-
*/
|
|
95
|
-
const child_process_1 = require("child_process");
|
|
96
|
-
const crypto = __importStar(require("crypto"));
|
|
97
|
-
const fs = __importStar(require("fs"));
|
|
98
|
-
const os = __importStar(require("os"));
|
|
99
|
-
const path = __importStar(require("path"));
|
|
100
|
-
const EXE_SRC = path.join(__dirname, "..", "summrise-agent.exe");
|
|
101
|
-
// C1 (2026-08-28): the registry is the single source of truth for the install
|
|
102
|
-
// dir. Resolution: $env:SUMMRISE_AGENT_DIR → HKLM\SOFTWARE\Summrise\Agent\InstallDir
|
|
103
|
-
// → default. No legacy directory probing — the installer/setup always write
|
|
104
|
-
// the registry, and all commands use this one DIR.
|
|
105
|
-
function resolveDir() {
|
|
106
|
-
if (process.env.SUMMRISE_AGENT_DIR)
|
|
107
|
-
return process.env.SUMMRISE_AGENT_DIR;
|
|
108
|
-
try {
|
|
109
|
-
const out = (0, child_process_1.spawnSync)("reg", ["query", "HKLM\\SOFTWARE\\Summrise\\Agent", "/v", "InstallDir"], { encoding: "utf8" });
|
|
110
|
-
if (out.status === 0 && out.stdout) {
|
|
111
|
-
const m = /REG_SZ\s+(.+)/.exec(out.stdout.split(/\r?\n/).find((l) => l.includes("InstallDir")) || "");
|
|
112
|
-
if (m && m[1].trim())
|
|
113
|
-
return m[1].trim();
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
/* fall through */
|
|
118
|
-
}
|
|
119
|
-
return "C:\\Program Files\\Summrise";
|
|
120
|
-
}
|
|
121
|
-
const DIR = resolveDir();
|
|
122
|
-
const EXE_DST = path.join(DIR, "summrise-agent.exe");
|
|
123
|
-
// Layout v2 (ADR 0008): the install ROOT keeps only the service exe (+ its
|
|
124
|
-
// transient .new/.old) and the NSIS uninstaller. Everything else lives in
|
|
125
|
-
// one of these — leaf names unchanged (Electron packaging + task arguments
|
|
126
|
-
// are rename-sensitive; only the parent moves).
|
|
127
|
-
const ETC_DIR = path.join(DIR, "etc");
|
|
128
|
-
const COMPONENTS_DIR = path.join(DIR, "components");
|
|
129
|
-
const SCRIPTS_DIR = path.join(DIR, "scripts");
|
|
130
|
-
// DataDir mirrors resolveDir (registry DataDir, else %ProgramData%\Summrise) —
|
|
131
|
-
// runtime logs + evidence live there, never in program files.
|
|
132
|
-
function resolveDataDir() {
|
|
133
|
-
try {
|
|
134
|
-
const out = (0, child_process_1.spawnSync)("reg", ["query", "HKLM\\SOFTWARE\\Summrise\\Agent", "/v", "DataDir"], { encoding: "utf8" });
|
|
135
|
-
if (out.status === 0 && out.stdout) {
|
|
136
|
-
const m = /REG_SZ\s+(.+)/.exec(out.stdout.split(/\r?\n/).find((l) => l.includes("DataDir")) || "");
|
|
137
|
-
if (m && m[1].trim())
|
|
138
|
-
return m[1].trim();
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
/* fall through */
|
|
143
|
-
}
|
|
144
|
-
return path.join(process.env.ProgramData || "C:\\ProgramData", "Summrise");
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Write one HKLM value and VERIFY IT. The registry is the single source of truth for
|
|
148
|
-
* path resolution — `paths.rs` on the Rust side and `resolveDataDir()` here both read it
|
|
149
|
-
* — so a silent failure means the agent and this CLI can disagree about where the install
|
|
150
|
-
* IS, and `summrise status` then prints the default dir and "panel: (not installed)" for an
|
|
151
|
-
* install that succeeded somewhere else.
|
|
152
|
-
*
|
|
153
|
-
* It used to be `spawnSync("reg", [...], { stdio: "ignore" })` inside a `try` that could
|
|
154
|
-
* never fire: spawnSync does NOT throw on a failing program, and stdio ignored suppressed
|
|
155
|
-
* the only evidence.
|
|
156
|
-
*/
|
|
157
|
-
function regWrite(name, value) {
|
|
158
|
-
const r = (0, child_process_1.spawnSync)("reg", [
|
|
159
|
-
"add",
|
|
160
|
-
"HKLM\\SOFTWARE\\Summrise\\Agent",
|
|
161
|
-
"/v",
|
|
162
|
-
name,
|
|
163
|
-
"/t",
|
|
164
|
-
"REG_SZ",
|
|
165
|
-
"/d",
|
|
166
|
-
value,
|
|
167
|
-
"/f",
|
|
168
|
-
], { encoding: "utf8" });
|
|
169
|
-
if (r.status !== 0) {
|
|
170
|
-
console.error(`setup: WARNING -- could not record ${name} in HKLM\\SOFTWARE\\Summrise\\Agent` +
|
|
171
|
-
(r.stderr ? ` (${String(r.stderr).trim()})` : "") +
|
|
172
|
-
" -- path resolution will fall back to the default");
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
const DATA_DIR = resolveDataDir();
|
|
178
|
-
const LOGS_DIR = path.join(DATA_DIR, "logs");
|
|
179
|
-
const CFG_FILE = path.join(ETC_DIR, "config.yaml");
|
|
180
|
-
const HOSTNAME_FILE = path.join(ETC_DIR, "summrise-agent.hostname");
|
|
181
|
-
const DESK_DIR = path.join(COMPONENTS_DIR, "summrise-desktop-electron");
|
|
182
|
-
const PW_DIR = path.join(COMPONENTS_DIR, "playwright");
|
|
183
|
-
const TASK = "SummriseAgent";
|
|
184
|
-
// Gateway API base — where the console endpoints live (tunnel-token /
|
|
185
|
-
// register). Overridable for staging.
|
|
186
|
-
const API_BASE = process.env.SUMMRISE_API_BASE || "https://api.saisi.online";
|
|
187
|
-
// POST JSON to the gateway, return parsed JSON or throw with the error text.
|
|
188
|
-
function apiPost(pathname, body) {
|
|
189
|
-
const res = (0, child_process_1.spawnSync)("curl", [
|
|
190
|
-
"-sS",
|
|
191
|
-
"-m",
|
|
192
|
-
"30",
|
|
193
|
-
"-X",
|
|
194
|
-
"POST",
|
|
195
|
-
"-H",
|
|
196
|
-
"content-type: application/json",
|
|
197
|
-
"-d",
|
|
198
|
-
JSON.stringify(body),
|
|
199
|
-
API_BASE + pathname,
|
|
200
|
-
], { encoding: "utf8" });
|
|
201
|
-
if (res.status !== 0)
|
|
202
|
-
throw new Error("gateway unreachable: " + (res.stderr || "").trim());
|
|
203
|
-
const out = (res.stdout || "").trim();
|
|
204
|
-
try {
|
|
205
|
-
return JSON.parse(out);
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
throw new Error("gateway bad response: " + out.slice(0, 120));
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
// ── `sh()` IS `cmd.exe /d /s /c "<string>"`, AND DOUBLE QUOTES DO NOT STOP `%` ──
|
|
212
|
-
//
|
|
213
|
-
// Quoting a value stops cmd from reading `&`, `|`, `>` and `^` as OPERATORS (that is
|
|
214
|
-
// the `psArgv` incident below). It does NOT stop PERCENT EXPANSION: cmd expands
|
|
215
|
-
// `%NAME%` inside a quoted region exactly as it does outside one, so an interpolated
|
|
216
|
-
// PATH that contains a `%` is still a variable reference rather than data. `%` is
|
|
217
|
-
// legal in an NTFS name, and the install/data dirs here are chosen by the operator,
|
|
218
|
-
// so this is reachable rather than theoretical.
|
|
219
|
-
//
|
|
220
|
-
// MEASURED on d1 (2026-09-25) through this exact `spawnSync(cmd, {shell:true})` path;
|
|
221
|
-
// each arrow is the child's own stdout:
|
|
222
|
-
// `echo [%CMDCMDLINE%]` -> [C:\WINDOWS\system32\cmd.exe /d /s /c "echo [%CMDCMDLINE%]"]
|
|
223
|
-
// `echo ["C:\%ProgramFiles%\Summrise"]` -> ["C:\C:\Program Files\Summrise"] (rewritten)
|
|
224
|
-
// shell `powershell ... "Write-Output '%ProgramFiles%'"` -> C:\Program Files (value lost)
|
|
225
|
-
// argv `powershell ... Write-Output '%ProgramFiles%'` -> %ProgramFiles% (verbatim)
|
|
226
|
-
//
|
|
227
|
-
// AND THE BATCH-FILE ESCAPE IS THE WRONG FIX: cmd's `%%` -> `%` rule belongs to batch
|
|
228
|
-
// FILES. On a `/d /s /c` command line that same measurement prints `[100%%]` for
|
|
229
|
-
// `echo [100%%]` — the doubling is NOT collapsed, so "escaping" a value with `%%`
|
|
230
|
-
// would put a DOUBLED sign into the path, which is worse than the defect. What
|
|
231
|
-
// remains is argv (no cmd in the path at all — `ps()` below hands PowerShell its
|
|
232
|
-
// script directly) or a value that provably cannot contain `%`. A site that keeps a
|
|
233
|
-
// cmd line anyway says which of those it is in a `cmd-% <kind>: <reason>` comment,
|
|
234
|
-
// and test/cli.test.mjs fails any interpolating site that does not.
|
|
235
|
-
//
|
|
236
|
-
// `rmdir`, `taskkill`, `sc`, `schtasks` and `reg` cannot take argv (the first is a
|
|
237
|
-
// cmd BUILTIN — there is no rmdir.exe), so a path handed to one of those must reach
|
|
238
|
-
// cmd as a `%NAME%` reference rather than as text.
|
|
239
|
-
function sh(cmd, opts = {}) {
|
|
240
|
-
return (0, child_process_1.spawnSync)(cmd, { shell: true, stdio: "inherit", ...opts });
|
|
241
|
-
}
|
|
242
|
-
/**
|
|
243
|
-
* The argv for a one-shot PowerShell script — NO SHELL, and that is the point.
|
|
244
|
-
*
|
|
245
|
-
* `shell: true` routed this through cmd.exe, where `"` is a quote TOGGLE and
|
|
246
|
-
* `\"` is not an escape at all. So cmd re-parsed the argument before PowerShell
|
|
247
|
-
* ever saw it, and any character it treats specially became an OPERATOR.
|
|
248
|
-
*
|
|
249
|
-
* Observed on d1: the round-7 update receipt contains `1.2.322 -> 1.2.323`.
|
|
250
|
-
* cmd saw the `>` and performed a REDIRECTION — the log line landed as
|
|
251
|
-
* "update requested 1.2.322 - (CLI reached the device...)" (arrow and TARGET
|
|
252
|
-
* VERSION gone, exactly the half that says what is being installed) and a stray
|
|
253
|
-
* zero-byte file named `1.2.323` appeared in the working directory. The
|
|
254
|
-
* receipt's whole purpose is to distinguish "the CLI ran but the swap did not"
|
|
255
|
-
* from "nothing ran"; a receipt that cannot name its target is half a receipt.
|
|
256
|
-
*
|
|
257
|
-
* Passing argv straight to CreateProcess means PowerShell receives the script
|
|
258
|
-
* VERBATIM and no quoting layer sits between the two. This is also what every
|
|
259
|
-
* other spawn in this file already does (`spawnSync("reg", [...])`) — `ps()` was
|
|
260
|
-
* the only one that shelled out.
|
|
261
|
-
*/
|
|
262
|
-
function psArgv(script) {
|
|
263
|
-
return ["-NoProfile", "-Command", script];
|
|
264
|
-
}
|
|
265
|
-
function ps(script) {
|
|
266
|
-
// npm audit #7: results were discarded — a failed Register-ScheduledTask
|
|
267
|
-
// printed SUCCESS anyway. Return the spawn result.
|
|
268
|
-
return (0, child_process_1.spawnSync)("powershell", psArgv(script), { stdio: "inherit" });
|
|
269
|
-
}
|
|
270
|
-
// Run a PowerShell script from a temp .ps1 FILE instead of -Command. The
|
|
271
|
-
// layout-migration script is ~10KB — past cmd.exe's 8191-char command-line
|
|
272
|
-
// limit — so `ps()` (which shells through cmd) failed it with "命令行太长"
|
|
273
|
-
// and the migration silently no-op'd. A file path is short, so the script
|
|
274
|
-
// length is then unbounded. This is a DIRECT child spawn (not the WMI
|
|
275
|
-
// handoff where -ExecutionPolicy Bypass dies silently on d1), so Bypass is
|
|
276
|
-
// safe here and sidesteps any Restricted execution policy on the box.
|
|
277
|
-
function psFile(script) {
|
|
278
|
-
const tmpDir = process.env.TEMP || process.env.TMP || "C:\\Windows\\Temp";
|
|
279
|
-
const tmp = path.join(tmpDir, `summrise-mig-${process.pid}.ps1`);
|
|
280
|
-
try {
|
|
281
|
-
fs.writeFileSync(tmp, script, "utf8");
|
|
282
|
-
return (0, child_process_1.spawnSync)("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmp], { encoding: "utf8" });
|
|
283
|
-
}
|
|
284
|
-
catch (e) {
|
|
285
|
-
console.log("setup: psFile failed (" + (e?.message || e) + ")");
|
|
286
|
-
return { status: 1 };
|
|
287
|
-
}
|
|
288
|
-
finally {
|
|
289
|
-
try {
|
|
290
|
-
fs.unlinkSync(tmp);
|
|
291
|
-
}
|
|
292
|
-
catch {
|
|
293
|
-
/* best-effort */
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
// npm audit #6: setup interpolated RAW paths into PS single-quote literals;
|
|
298
|
-
// an apostrophe in a path (O'Brien) unbalanced the literal and the script
|
|
299
|
-
// PARSE-failed invisibly. Shared doubling helper.
|
|
300
|
-
// exported: unit-tested in test/cli.test.mjs (SYSTEM-context PS quoting = injection surface)
|
|
301
|
-
const psq = (x) => String(x).replace(/'/g, "''");
|
|
302
|
-
exports.psq = psq;
|
|
303
|
-
/** The taskbar identity: ONE string, and it lives on BOTH sides of an association.
|
|
304
|
-
*
|
|
305
|
-
* It is the AppUserModelID the desktop shell sets on its process
|
|
306
|
-
* (`agent/summrise-desktop-electron/src/main.ts`) and the value written into
|
|
307
|
-
* `System.AppUserModel.ID` on the shortcut below. Microsoft's lookup for the taskbar
|
|
308
|
-
* group's icon takes the shortcut arm only when the window carries an explicit ID, and
|
|
309
|
-
* the window's arm only names a shortcut carrying the SAME ID — so the two halves are one
|
|
310
|
-
* association and this is the string that ties them. If they ever disagree, the icon
|
|
311
|
-
* silently reverts to the backing executable (stock `electron.exe`, since the shell runs
|
|
312
|
-
* unpackaged) with nothing on the device to read that says why.
|
|
313
|
-
* PowerShell/Windows AppUserModelIDs cannot contain spaces, and this one does not.
|
|
314
|
-
*
|
|
315
|
-
* WHY IT IS NOT `online.saisi.summrise.agent`, WHICH IS WHAT IT WAS CALLED BEFORE —
|
|
316
|
-
* MEASURED on desktop-14rjcr8 (release 1.2.490), ONE VARIABLE AT A TIME:
|
|
317
|
-
*
|
|
318
|
-
* | window's AppUserModelID | shortcut carries it | taskbar draws |
|
|
319
|
-
* | window's AppUserModelID | shortcuts carrying it | taskbar draws |
|
|
320
|
-
* | online.saisi.summrise.agent | both desktops + Start Menu | Electron logo |
|
|
321
|
-
* | online.saisi.summrise.desktop | both desktops + Start Menu | THE SUNRISE |
|
|
322
|
-
* | online.saisi.summrise.desktop | both desktops, Start Menu GONE | Electron logo |
|
|
323
|
-
*
|
|
324
|
-
* That third row is the one that decides where the identity has to live, and it was taken
|
|
325
|
-
* WITHOUT restarting anything: the same running shell, the same AppUserModelID already set,
|
|
326
|
-
* and the icon fell back to the Electron logo the moment the Start Menu shortcut was
|
|
327
|
-
* deleted. So the shortcut this lookup resolves is the **Start Menu** one — a desktop
|
|
328
|
-
* shortcut is not enough, and writing the identity onto `Summrise.lnk` in
|
|
329
|
-
* `%PUBLIC%\Desktop` alone changes nothing. Microsoft's appids.md does say to apply the ID
|
|
330
|
-
* to "all of the shortcuts" when an application has several launch locations, and it lists
|
|
331
|
-
* the Start menu FIRST; the measurement is what says which of them the taskbar actually
|
|
332
|
-
* reads.
|
|
333
|
-
*
|
|
334
|
-
* The other variables were held: the same `IPropertyStore` call read back on every
|
|
335
|
-
* shortcut, the same `icon.ico` (downloaded and RENDERED — it is the orange sunrise), the
|
|
336
|
-
* same shell restarted under the `SummriseDesktop` task, and in each restarted run the
|
|
337
|
-
* running process appended `AUMID-SET-OK` to a log from inside `setAppUserModelId`, so "the
|
|
338
|
-
* ID was set" is measured rather than assumed.
|
|
339
|
-
*
|
|
340
|
-
* WHY THE OLD ID WAS DEAD. Releases up to 1.2.489 set `online.saisi.summrise.agent` on the
|
|
341
|
-
* process while no shortcut anywhere carried it, so the shell resolved that ID against the
|
|
342
|
-
* backing executable — stock `electron.exe` — and kept the result. `shell:AppsFolder` still
|
|
343
|
-
* lists it as `name=[Electron] path=[online.saisi.summrise.agent]`, an app entry named after
|
|
344
|
-
* `electron.exe`. An ID already resolved to the wrong image is not repaired by writing the
|
|
345
|
-
* shortcut afterwards; the association has to be under a name the machine has never seen.
|
|
346
|
-
* Keep this string STABLE from here: changing it again costs the pin and the jump list, so
|
|
347
|
-
* this is a one-time migration, not a knob.
|
|
348
|
-
*/
|
|
349
|
-
exports.DESKTOP_AUMID = "online.saisi.summrise.desktop";
|
|
350
|
-
/** The two `IPropertyStore` values `Summrise.lnk` must carry, as DATA.
|
|
351
|
-
*
|
|
352
|
-
* Deliberately separated from the PowerShell that performs the write, and exported, so the
|
|
353
|
-
* DECISION is testable where the COM call is not: a test can pin what the CLI intends to
|
|
354
|
-
* write without a Windows box. `icoPath` is the shell's own sunrise `.ico`; the `,0` is the
|
|
355
|
-
* icon index and is REQUIRED — the property's own documentation: "because .ico files can
|
|
356
|
-
* contain multiple icon resources, a resource ID is required in the string. If the .ico
|
|
357
|
-
* file is a single image, use '0'".
|
|
358
|
-
*
|
|
359
|
-
* `IconLocation` on the shortcut is a DIFFERENT thing and is left alone: it is what
|
|
360
|
-
* Explorer draws for the .lnk FILE. These two are what the taskbar GROUP uses.
|
|
361
|
-
*/
|
|
362
|
-
function lnkIdentity(icoPath) {
|
|
363
|
-
return { id: exports.DESKTOP_AUMID, icon: `${icoPath},0` };
|
|
364
|
-
}
|
|
365
|
-
/** C# interop for the two shortcut properties `WScript.Shell` cannot set.
|
|
366
|
-
*
|
|
367
|
-
* WHY THIS MECHANISM, given this repository prefers Rust for logic: `System.AppUserModel.ID`
|
|
368
|
-
* and `System.AppUserModel.RelaunchIconResource` are not fields of the .lnk header — they
|
|
369
|
-
* live in the shortcut's `IPropertyStore`, and `WScript.Shell` exposes no way to reach one.
|
|
370
|
-
* The route Microsoft documents for a shortcut is `IShellLink` + `QueryInterface` for
|
|
371
|
-
* `IPropertyStore` ("A shortcut (as an IShellLink, CLSID_ShellLink, or a .lnk file) supports
|
|
372
|
-
* properties through IPropertyStore", shell/appids.md) — COM interop, in the launcher's own
|
|
373
|
-
* script generator, which is already PowerShell and already writing this shortcut.
|
|
374
|
-
* `Add-Type` compiles it with the C# compiler that ships IN the box: nothing installed,
|
|
375
|
-
* nothing downloaded, and it runs on a stock Windows box with no Node, no Rust and no SDK.
|
|
376
|
-
* A Rust helper is the repository's usual preference and was rejected HERE on cost, not on
|
|
377
|
-
* taste: it needs a new cross-compiled binary in the npm package, and this package "carries
|
|
378
|
-
* NO boxed components" by design — `setup` fetches and hash-pins them — so ~60 lines of
|
|
379
|
-
* interop would buy a build stage, a release-host component and a new pin.
|
|
380
|
-
*
|
|
381
|
-
* TWO THINGS THE DEVICE TAUGHT, both load-bearing:
|
|
382
|
-
* - `IPersistFile::Load` must be given STGM_READWRITE (2). Loading with 0 opens the link
|
|
383
|
-
* READ-ONLY and the later `IPropertyStore::Commit` fails STG_E_ACCESSDENIED (0x80030005)
|
|
384
|
-
* — measured on desktop-14rjcr8, where the first version of this passed 0 and wrote
|
|
385
|
-
* nothing while reporting no error the caller could see.
|
|
386
|
-
* - the write is verified by READING THE PROPERTIES BACK with `Read`, which is also what
|
|
387
|
-
* decides whether the shortcut needs repairing at all.
|
|
388
|
-
*/
|
|
389
|
-
const LNK_PROPS_CS = `using System;
|
|
390
|
-
using System.Runtime.InteropServices;
|
|
391
|
-
public static class SummriseLnk {
|
|
392
|
-
[StructLayout(LayoutKind.Sequential, Pack = 4)]
|
|
393
|
-
struct PROPERTYKEY { public Guid fmtid; public uint pid; }
|
|
394
|
-
[StructLayout(LayoutKind.Sequential)]
|
|
395
|
-
struct PROPVARIANT { public ushort vt, r1, r2, r3; public IntPtr p, p2; }
|
|
396
|
-
[ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
397
|
-
interface IPropertyStore {
|
|
398
|
-
int GetCount(out uint c);
|
|
399
|
-
int GetAt(uint i, out PROPERTYKEY k);
|
|
400
|
-
int GetValue(ref PROPERTYKEY k, out PROPVARIANT v);
|
|
401
|
-
int SetValue(ref PROPERTYKEY k, ref PROPVARIANT v);
|
|
402
|
-
int Commit();
|
|
403
|
-
}
|
|
404
|
-
[ComImport, Guid("0000010b-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
405
|
-
interface IPersistFile {
|
|
406
|
-
void GetClassID(out Guid g);
|
|
407
|
-
[PreserveSig] int IsDirty();
|
|
408
|
-
void Load([MarshalAs(UnmanagedType.LPWStr)] string f, uint m);
|
|
409
|
-
void Save([MarshalAs(UnmanagedType.LPWStr)] string f, [MarshalAs(UnmanagedType.Bool)] bool r);
|
|
410
|
-
void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string f);
|
|
411
|
-
void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string f);
|
|
412
|
-
}
|
|
413
|
-
static readonly Guid CLSID = new Guid("00021401-0000-0000-C000-000000000046");
|
|
414
|
-
static readonly Guid FMT = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3");
|
|
415
|
-
const uint RW = 2;
|
|
416
|
-
static PROPERTYKEY K(uint pid) { PROPERTYKEY k; k.fmtid = FMT; k.pid = pid; return k; }
|
|
417
|
-
static object New(string lnk, uint mode) {
|
|
418
|
-
object o = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID));
|
|
419
|
-
((IPersistFile)o).Load(lnk, mode);
|
|
420
|
-
return o;
|
|
421
|
-
}
|
|
422
|
-
static string Get(string lnk, uint pid, uint mode) {
|
|
423
|
-
IPropertyStore s = (IPropertyStore)New(lnk, mode);
|
|
424
|
-
PROPERTYKEY k = K(pid); PROPVARIANT v;
|
|
425
|
-
if (s.GetValue(ref k, out v) != 0) return "";
|
|
426
|
-
if (v.vt != 31 || v.p == IntPtr.Zero) return "";
|
|
427
|
-
string r = Marshal.PtrToStringUni(v.p);
|
|
428
|
-
Marshal.FreeCoTaskMem(v.p);
|
|
429
|
-
return r;
|
|
430
|
-
}
|
|
431
|
-
static void Put(IPropertyStore s, uint pid, string val) {
|
|
432
|
-
PROPERTYKEY k = K(pid); PROPVARIANT v = new PROPVARIANT();
|
|
433
|
-
v.vt = 31; v.p = Marshal.StringToCoTaskMemUni(val);
|
|
434
|
-
try { int hr = s.SetValue(ref k, ref v); if (hr != 0) Marshal.ThrowExceptionForHR(hr); }
|
|
435
|
-
finally { Marshal.FreeCoTaskMem(v.p); }
|
|
436
|
-
}
|
|
437
|
-
public static string Read(string lnk) { return Get(lnk, 5, 0) + "|" + Get(lnk, 3, 0); }
|
|
438
|
-
public static void Write(string lnk, string id, string icon) {
|
|
439
|
-
object o = New(lnk, RW);
|
|
440
|
-
IPropertyStore s = (IPropertyStore)o;
|
|
441
|
-
Put(s, 5, id); Put(s, 3, icon);
|
|
442
|
-
int hr = s.Commit();
|
|
443
|
-
if (hr != 0) Marshal.ThrowExceptionForHR(hr);
|
|
444
|
-
((IPersistFile)o).Save(lnk, true);
|
|
445
|
-
}
|
|
446
|
-
}`;
|
|
447
|
-
// stage-brand: healed desktop shortcut. A 2026-09-01 Summrise.lnk on devices
|
|
448
|
-
// points at the RETIRED Tauri summrise-desktop.exe (embedded stale icon) —
|
|
449
|
-
// double-clicking it launches the dead app instead of the Electron shell.
|
|
450
|
-
// Repair (only if the link already exists — headless installs must not
|
|
451
|
-
// sprout desktop icons): repoint at start-desktop.ps1 (the SummriseDesktop
|
|
452
|
-
// onlogon path) with IconLocation pinned to the sunrise icon.ico, remove the
|
|
453
|
-
// retired Tauri/tray orphans nothing ships anymore, AND write the taskbar
|
|
454
|
-
// identity (System.AppUserModel.ID + RelaunchIconResource) into the link's
|
|
455
|
-
// property store.
|
|
456
|
-
//
|
|
457
|
-
// WHY THE IDENTITY IS PART OF *THIS* FUNCTION: a shortcut with the right target
|
|
458
|
-
// and the wrong (or absent) identity is exactly the state that draws the
|
|
459
|
-
// Electron logo — the shell sets the same AppUserModelID on its process, and the
|
|
460
|
-
// taskbar's icon lookup reaches the shortcut arm only when a shortcut carrying
|
|
461
|
-
// that ID can be found. MEASURED on desktop-14rjcr8 before this change: both
|
|
462
|
-
// Summrise.lnk files existed and carried NEITHER property, which is why an
|
|
463
|
-
// explicit ID on the window alone changed nothing and why deleting that ID
|
|
464
|
-
// (d4edbb64) changed nothing either.
|
|
465
|
-
//
|
|
466
|
-
// Best-effort + logged, never fatal. `sink` is a PS output pipe
|
|
467
|
-
// (e.g. Write-Host, or the update log pipe). Single-quoted PS literals
|
|
468
|
-
// only (npm audit #6) except the double quotes the .lnk Arguments path
|
|
469
|
-
// needs — those are LITERAL characters, because they sit inside
|
|
470
|
-
// single-quoted PS strings, so NO caller escapes them: `setup` hands this
|
|
471
|
-
// script to `ps()` as argv and the update swap runs it from a file. The
|
|
472
|
-
// backslash-escape `setup` used to apply was deleted with that conversion
|
|
473
|
-
// (see step 7), which is why this line no longer says "escape them".
|
|
474
|
-
// The C# rides in a single-quoted HERE-STRING (`@'` … `'@`, both alone on
|
|
475
|
-
// their line): it contains double quotes and is literal end to end, so the
|
|
476
|
-
// doubling rule above cannot be defeated by anything inside it.
|
|
477
|
-
// exported: unit-tested in test/cli.test.mjs.
|
|
478
|
-
function deskShortcutRepairPs(scriptsQ, deskDirQ, sink) {
|
|
479
|
-
const { id, icon } = lnkIdentity(`${deskDirQ}\\icon.ico`);
|
|
480
|
-
// BOTH shortcuts get the identity, and the START MENU one is the load-bearing half:
|
|
481
|
-
// deleting it from a running shell reverted the taskbar to the Electron logo with the ID
|
|
482
|
-
// still set on the process and both desktop shortcuts still carrying it (see DESKTOP_AUMID).
|
|
483
|
-
// The desktop link is kept because it is what a person double-clicks and because
|
|
484
|
-
// appids.md says to apply the ID to "all of the shortcuts" a user can launch from.
|
|
485
|
-
const lnk = (p, tag) => [
|
|
486
|
-
` try { $dWs${tag} = New-Object -ComObject WScript.Shell; $dSc${tag} = $dWs${tag}.CreateShortcut(${p}); $dSc${tag}.TargetPath = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'; $dSc${tag}.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $dPs1 + '"'; $dSc${tag}.WorkingDirectory = '${deskDirQ}'; $dSc${tag}.IconLocation = $dIco + ',0'; $dSc${tag}.Save(); 'desk: ' + ${p} + ' repointed to electron shell' | ${sink} } catch { ('desk: ' + ${p} + ' repair failed: ' + $_.Exception.Message) | ${sink} }`,
|
|
487
|
-
` if ($dSet) { try { [SummriseLnk]::Write(${p}, $dId, $dRes); ('desk: ' + ${p} + ' taskbar identity [' + [SummriseLnk]::Read(${p}) + ']') | ${sink} } catch { ('desk: ' + ${p} + ' identity write failed: ' + $_.Exception.Message) | ${sink} } }`,
|
|
488
|
-
];
|
|
489
|
-
return [
|
|
490
|
-
`$dLnk = Join-Path $env:PUBLIC 'Desktop\\Summrise.lnk'`,
|
|
491
|
-
// ALL-USERS Start Menu: the shortcut the shell's app resolver actually reads, and it is
|
|
492
|
-
// CREATED here when absent — a machine that never had one is exactly the machine whose
|
|
493
|
-
// taskbar shows the Electron logo, and no amount of desktop-side repair reaches it.
|
|
494
|
-
// `setup` already requires elevation (HKLM writes, Program Files), so ProgramData is
|
|
495
|
-
// writable on every path that calls this.
|
|
496
|
-
`$dSm = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\Summrise.lnk'`,
|
|
497
|
-
`$dIco = '${deskDirQ}\\icon.ico'`,
|
|
498
|
-
`$dPs1 = '${scriptsQ}\\start-desktop.ps1'`,
|
|
499
|
-
`$dId = '${id}'`,
|
|
500
|
-
`$dRes = '${icon}'`,
|
|
501
|
-
`$dSet = $false`,
|
|
502
|
-
`$dShell = (Test-Path $dIco) -and (Test-Path $dPs1)`,
|
|
503
|
-
// Compiled when there is a SHELL to point at, not when a desktop link happens to exist:
|
|
504
|
-
// the Start Menu shortcut is created from nothing, so gating this on `$dLnk` would skip
|
|
505
|
-
// the identity on precisely the installs that need it. The `-as [type]` guard keeps a
|
|
506
|
-
// second call in one process from turning a "type already exists" throw into a silent
|
|
507
|
-
// skip of every write below.
|
|
508
|
-
`if ($dShell) {`,
|
|
509
|
-
` $dCs = @'\n${LNK_PROPS_CS}\n'@`,
|
|
510
|
-
` try { if (-not ('SummriseLnk' -as [type])) { Add-Type -TypeDefinition $dCs }; $dSet = [bool]('SummriseLnk' -as [type]) } catch { ('desk: lnk identity helper unavailable: ' + $_.Exception.Message) | ${sink} }`,
|
|
511
|
-
`}`,
|
|
512
|
-
`$dNeed = $false`,
|
|
513
|
-
`if (Test-Path $dLnk) {`,
|
|
514
|
-
` try { $dEx = (New-Object -ComObject WScript.Shell).CreateShortcut($dLnk); if (($dEx.TargetPath -like '*summrise-desktop.exe') -or ($dEx.TargetPath -like '*summrise-tray.exe') -or (-not (Test-Path $dEx.TargetPath))) { $dNeed = $true } } catch { $dNeed = $true }`,
|
|
515
|
-
// ...and a link that is otherwise healthy still needs repairing when its identity is
|
|
516
|
-
// missing or names a different ID/icon. `Read` returns "id|icon", so ONE comparison
|
|
517
|
-
// covers both properties — and it is the same call the verification below prints.
|
|
518
|
-
` if ($dSet) { try { if (([SummriseLnk]::Read($dLnk)) -ne ($dId + '|' + $dRes)) { $dNeed = $true } } catch { $dNeed = $true } }`,
|
|
519
|
-
`}`,
|
|
520
|
-
// The Start Menu link is created when ABSENT and rewritten when its identity is wrong —
|
|
521
|
-
// it is never deleted, and a link that already matches is left alone so a second `setup`
|
|
522
|
-
// does not churn the file.
|
|
523
|
-
`$dSmNeed = $false`,
|
|
524
|
-
`if ($dShell) {`,
|
|
525
|
-
` if (-not (Test-Path $dSm)) { $dSmNeed = $true }`,
|
|
526
|
-
` elseif (-not $dSet) { $dSmNeed = $true }`,
|
|
527
|
-
` else { try { if (([SummriseLnk]::Read($dSm)) -ne ($dId + '|' + $dRes)) { $dSmNeed = $true } } catch { $dSmNeed = $true } }`,
|
|
528
|
-
`}`,
|
|
529
|
-
`if ($dNeed -and $dShell) {`,
|
|
530
|
-
...lnk("$dLnk", "1"),
|
|
531
|
-
`}`,
|
|
532
|
-
`if ($dSmNeed -and $dShell) {`,
|
|
533
|
-
...lnk("$dSm", "2"),
|
|
534
|
-
`}`,
|
|
535
|
-
`foreach ($dRx in @('summrise-desktop.exe','summrise-tray.exe')) { $dRp = '${deskDirQ}\\' + $dRx; if (Test-Path $dRp) { try { Remove-Item -Force -ErrorAction Stop $dRp; ('desk: removed retired ' + $dRx) | ${sink} } catch { ('desk: retired ' + $dRx + ' locked, kept') | ${sink} } } }`,
|
|
536
|
-
];
|
|
537
|
-
}
|
|
538
|
-
// ── THE DESKTOP SHELL'S RECORD: WHAT THE LAUNCHERS CAN SEE, AND WHAT THEY CANNOT ──
|
|
539
|
-
//
|
|
540
|
-
// MEASURED on desktop-14rjcr8 (2026-09-28). The desktop shell was KILLED every ten minutes
|
|
541
|
-
// by `<ExecutionTimeLimit>PT10M</ExecutionTimeLimit>` on the SummriseDesktop task: the
|
|
542
|
-
// launcher ends on `& electron.exe .` — the PowerShell CALL OPERATOR, which WAITS — so the
|
|
543
|
-
// shell lives inside the task's process tree and the scheduler killed the whole tree when
|
|
544
|
-
// the limit expired. The guarded 5-minute pulse then revived it, correctly, and ten minutes
|
|
545
|
-
// later it died again. `logs\startup.log` held 4,015 lines and answered ZERO for `desktop`,
|
|
546
|
-
// ZERO for `electron` and ZERO for `ExecutionTimeLimit`. Nothing in the product recorded
|
|
547
|
-
// it; the operator said "还一直在重启呢" four times, and the answer had to be recovered from
|
|
548
|
-
// the Task Scheduler's own operational log, where Summrise is one line among a stream of
|
|
549
|
-
// Edge update tasks.
|
|
550
|
-
//
|
|
551
|
-
// THE SHAPE ALREADY EXISTED ONE PROCESS OVER. The agent has exactly the right sentence
|
|
552
|
-
// about itself in its own run journal — `runstate.rs`: "previous run DID NOT EXIT CLEANLY —
|
|
553
|
-
// REPLACED by a restart" — and the shell, which is the process that IS killed from outside,
|
|
554
|
-
// had no counterpart. These are that counterpart.
|
|
555
|
-
//
|
|
556
|
-
// WHERE THE LINES GO, AND WHY THERE. `logs\startup.log`, under the SAME registry-first DataDir
|
|
557
|
-
// the agent's own `paths.rs` resolves (`DATA_DIR` above — never a hardcoded ProgramData, and
|
|
558
|
-
// the value is baked in at generation time by the process that DID the lookup). `startup.log`
|
|
559
|
-
// is the file the operator actually grepped. A record in a second file nobody knows to open
|
|
560
|
-
// would be tonight's failure one level down.
|
|
561
|
-
//
|
|
562
|
-
// EVERY LINE CONTAINS THE WHOLE WORD `desktop` — the search that failed was
|
|
563
|
-
// `findstr /i desktop ...\logs\startup.log` — and names `electron` wherever it is the subject.
|
|
564
|
-
//
|
|
565
|
-
// AND THE ONE CASE THAT CANNOT BE RECORDED IS WRITTEN DOWN WHERE IT HAPPENS, in
|
|
566
|
-
// `startDesktopPs` below: a Task Scheduler kill takes the logging process with it, so it
|
|
567
|
-
// leaves NO exit code, ever. The next pulse reports that ABSENCE out loud.
|
|
568
|
-
/** The desktop shell's launcher — and the only place in the product that can see it EXIT.
|
|
569
|
-
*
|
|
570
|
-
* `exit code N` is what tells a crash from a kill, and GETTING IT COST A MEASUREMENT. The
|
|
571
|
-
* obvious form was `& "$dir\node_modules\electron\dist\electron.exe" .` then `$LASTEXITCODE`,
|
|
572
|
-
* and it does not work: **electron.exe is a GUI-subsystem binary and Windows PowerShell 5.1
|
|
573
|
-
* does not set `$LASTEXITCODE` for one.** Measured on desktop-14rjcr8, 2026-09-28:
|
|
574
|
-
*
|
|
575
|
-
* & cmd /c 'exit 7' -> $LASTEXITCODE = 7
|
|
576
|
-
* & electron.exe --version -> returned in 0.05s, $LASTEXITCODE STILL 7 (unchanged)
|
|
577
|
-
* [Diagnostics.Process] variant -> waited, .ExitCode = 0
|
|
578
|
-
*
|
|
579
|
-
* The first version of this file used `&`, and the line it wrote on a real restart read
|
|
580
|
-
* `desktop: electron.exe exited (start=1790589628 exit code lived=1s)` — an empty field
|
|
581
|
-
* where a number belongs. That is the same defect this whole record exists to remove (a
|
|
582
|
-
* surface reporting a step in the grammar of an outcome), so the launch goes through
|
|
583
|
-
* `System.Diagnostics.Process` with `UseShellExecute = $false`: the SAME CreateProcess the
|
|
584
|
-
* call operator uses, same inherited handles, same working directory — plus `WaitForExit()`
|
|
585
|
-
* and a real `.ExitCode`.
|
|
586
|
-
*
|
|
587
|
-
* `WaitForExit()` IS LOAD-BEARING BEYOND THE EXIT CODE: it is why this PowerShell stays
|
|
588
|
-
* alive as the shell's parent, which is why the shell lives inside the task's process tree.
|
|
589
|
-
*
|
|
590
|
-
* WHAT IT CANNOT SEE, AND IT IS THE CASE THAT HAPPENED. The task's action is
|
|
591
|
-
* desktop-pulse.vbs -> ensure-desktop.ps1 -> THIS FILE, and Task Scheduler's
|
|
592
|
-
* `<ExecutionTimeLimit>` terminates that whole tree — electron, this PowerShell and the
|
|
593
|
-
* wscript above it. So a scheduler kill records NOTHING here, and neither do `taskkill /T`
|
|
594
|
-
* and a machine going down: the process that would write the line dies with the shell. On
|
|
595
|
-
* 2026-09-28 PT10M killed the shell every ten minutes and this file, as it stood, said
|
|
596
|
-
* nothing at all. The signature is therefore the ABSENCE of an `exited (start=N …)` line for
|
|
597
|
-
* a start the watchdog watched disappear, and `ensureDesktopPs` says those words on the next
|
|
598
|
-
* pulse rather than leaving the gap to be interpreted.
|
|
599
|
-
*
|
|
600
|
-
* Launches the Electron shell from components\summrise-desktop-electron\ with the working
|
|
601
|
-
* directory set there (electron . resolves src/main.js via package.json main). Intentionally
|
|
602
|
-
* small + ASCII-only (system-locale PS). unit-tested. */
|
|
603
|
-
function startDesktopPs(deskDirQ, logsQ) {
|
|
604
|
-
return [
|
|
605
|
-
"# written by `summrise setup` / `summrise desktop` / `summrise update` -- start the",
|
|
606
|
-
"# Electron shell, and leave a record of the launch and of the exit. The comments stay",
|
|
607
|
-
"# IN the file on purpose: this is what an operator reads when the window is gone.",
|
|
608
|
-
"#",
|
|
609
|
-
"# WHAT THIS CANNOT SEE. The launch below WAITS, so it returns with the shell's exit code",
|
|
610
|
-
"# when the shell exits OR crashes. It does NOT return when the whole process tree is",
|
|
611
|
-
"# killed from outside -- a Task Scheduler execution limit, a `taskkill /T`, a machine",
|
|
612
|
-
"# going down -- because this PowerShell dies first, and then NO exit code is written at",
|
|
613
|
-
"# all. ensure-desktop.ps1 reports that absence on the next pulse; a log that recorded",
|
|
614
|
-
"# only exits would silently omit the one death that actually happens here.",
|
|
615
|
-
"#",
|
|
616
|
-
"# WHY NOT `& electron.exe .` AND $LASTEXITCODE (measured on desktop-14rjcr8, 2026-09-28):",
|
|
617
|
-
"# electron.exe is a GUI-subsystem binary and Windows PowerShell 5.1 does not set",
|
|
618
|
-
"# $LASTEXITCODE for one. `& cmd /c 'exit 7'` set it to 7; the next `& electron.exe",
|
|
619
|
-
"# --version` left it at 7, unchanged. The first version of this file used `&` and wrote",
|
|
620
|
-
"# `exit code ` with the number missing -- a field that looks like a value and is not one.",
|
|
621
|
-
"# System.Diagnostics.Process is the same CreateProcess, and its ExitCode is real.",
|
|
622
|
-
`$dir = '${deskDirQ}'`,
|
|
623
|
-
`$log = '${logsQ}\\startup.log'`,
|
|
624
|
-
"New-Item -ItemType Directory -Force -Path (Split-Path $log) | Out-Null",
|
|
625
|
-
"$E = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()",
|
|
626
|
-
"Add-Content -Path $log -Value ('[' + (Get-Date -Format o) + '] desktop: electron.exe launching from ' + $dir + ' (start=' + $E + ')') -ErrorAction SilentlyContinue",
|
|
627
|
-
"Set-Location $dir",
|
|
628
|
-
"$code = $null",
|
|
629
|
-
"try {",
|
|
630
|
-
" $si = New-Object System.Diagnostics.ProcessStartInfo",
|
|
631
|
-
" $si.FileName = \"$dir\\node_modules\\electron\\dist\\electron.exe\"",
|
|
632
|
-
" $si.Arguments = '.'",
|
|
633
|
-
" $si.WorkingDirectory = $dir",
|
|
634
|
-
" $si.UseShellExecute = $false",
|
|
635
|
-
" $p = [System.Diagnostics.Process]::Start($si)",
|
|
636
|
-
" $p.WaitForExit()",
|
|
637
|
-
" $code = $p.ExitCode",
|
|
638
|
-
"} catch { $code = $null }",
|
|
639
|
-
"$L = [int]([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $E)",
|
|
640
|
-
"if ($null -ne $code) {",
|
|
641
|
-
" Add-Content -Path $log -Value ('[' + (Get-Date -Format o) + '] desktop: electron.exe exited (start=' + $E + ' exit code ' + $code + ' lived=' + $L + 's)') -ErrorAction SilentlyContinue",
|
|
642
|
-
"} else {",
|
|
643
|
-
" Add-Content -Path $log -Value ('[' + (Get-Date -Format o) + '] desktop: electron.exe DID NOT RUN (start=' + $E + ') -- the launcher could not start it, so there is no exit code') -ErrorAction SilentlyContinue",
|
|
644
|
-
"}",
|
|
645
|
-
];
|
|
646
|
-
}
|
|
647
|
-
/** The desktop shell's WATCHDOG: start the shell when none is running, and RECORD WHICH OF
|
|
648
|
-
* THE TWO THINGS HAPPENED — in the words a person would search for.
|
|
649
|
-
*
|
|
650
|
-
* Run every 5 minutes by `desktop-pulse.vbs` under the SummriseDesktop task, and once at
|
|
651
|
-
* logon. `desktop-pulse.vbs` itself takes no decision and carries no record.
|
|
652
|
-
*
|
|
653
|
-
* WRITTEN FROM ONE BUILDER, BY ALL THREE PATHS THAT WRITE IT — `summrise setup`,
|
|
654
|
-
* `summrise desktop` and the update swap — because a second copy of this text is a copy
|
|
655
|
-
* that drifts, and the drift would silently revert the record on whichever path was not
|
|
656
|
-
* edited (the shape `installer_integrity.rs` already holds the task definition against).
|
|
657
|
-
*
|
|
658
|
-
* THE HEALTHY CASE IS LOGGED, ONCE AN HOUR, AND BOTH HALVES OF THAT ARE DELIBERATE.
|
|
659
|
-
*
|
|
660
|
-
* A line per 5-minute pulse is 288 lines a day of "still fine" in `startup.log`, which is
|
|
661
|
-
* the AGENT's boot record and rotates at 1 MB (`main.rs::log_line`). That heartbeat would
|
|
662
|
-
* evict the history of a real incident — the opposite of the point — and an operator
|
|
663
|
-
* grepping `desktop` after a restart would have to wade through it to find the restart.
|
|
664
|
-
*
|
|
665
|
-
* Writing NOTHING in the healthy case is worse in the other direction: a watchdog that has
|
|
666
|
-
* stopped and a watchdog that is fine would look identical, because both are SILENCE — this
|
|
667
|
-
* repository's own recorded defect class, where "if nothing was checked, say that" is the
|
|
668
|
-
* honest form. So: ONE line an hour (~2 KB a day), deduplicated against the LOG ITSELF
|
|
669
|
-
* rather than a state file (the record is the state, so there is nothing to get out of
|
|
670
|
-
* sync), and a gap longer than about two hours is the signal that the pulse itself stopped.
|
|
671
|
-
*
|
|
672
|
-
* THE DECISION IS STATED AS THE OBSERVATION, NOT AS THE STEP: "no electron shell is
|
|
673
|
-
* running" is what the pulse SAW; the launch it then performs is the consequence. When the
|
|
674
|
-
* shell that disappeared left no `exited (start=N …)` line, this says so in words — that is
|
|
675
|
-
* the scheduler-kill signature, and it is exactly the case `startDesktopPs` cannot record.
|
|
676
|
-
*
|
|
677
|
-
* ASCII-only (system-locale PS). unit-tested. */
|
|
678
|
-
function ensureDesktopPs(scriptsQ, logsQ) {
|
|
679
|
-
return [
|
|
680
|
-
"# written by `summrise setup` / `summrise desktop` / `summrise update` -- the desktop",
|
|
681
|
-
"# shell's watchdog, run every 5 minutes by desktop-pulse.vbs under SummriseDesktop.",
|
|
682
|
-
"# Start the shell if none is running, and RECORD WHICH OF THE TWO THINGS HAPPENED:",
|
|
683
|
-
"# silence is not a status. The builder in src/summrise.ts carries the full reasoning,",
|
|
684
|
-
"# including why the healthy case is one line an hour and not one per pulse.",
|
|
685
|
-
`$log = '${logsQ}\\startup.log'`,
|
|
686
|
-
"New-Item -ItemType Directory -Force -Path (Split-Path $log) | Out-Null",
|
|
687
|
-
"$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()",
|
|
688
|
-
"$tail = @(Get-Content -Path $log -Tail 400 -ErrorAction SilentlyContinue)",
|
|
689
|
-
"$p = @(Get-Process electron -ErrorAction SilentlyContinue)",
|
|
690
|
-
"if ($p.Count -gt 0) {",
|
|
691
|
-
" $h = 'hour=' + (Get-Date -Format 'yyyy-MM-ddTHH')",
|
|
692
|
-
" if (-not ($tail -match ('desktop: electron shell alive .*' + $h))) {",
|
|
693
|
-
" $up = ($p | Sort-Object StartTime | Select-Object -First 1).StartTime",
|
|
694
|
-
" Add-Content -Path $log -Value ('[' + (Get-Date -Format o) + '] desktop: electron shell alive -- ' + $p.Count + ' process(es), oldest since ' + $up + ' ' + $h) -ErrorAction SilentlyContinue",
|
|
695
|
-
" }",
|
|
696
|
-
" exit",
|
|
697
|
-
"}",
|
|
698
|
-
"$was = 'no earlier launch is recorded in the log tail'",
|
|
699
|
-
// THE PATTERN MUST MATCH THE LINE `startDesktopPs` ACTUALLY WRITES, and the first version of
|
|
700
|
-
// it did NOT: it read `launching \(start=` while the launcher writes `launching from <dir>
|
|
701
|
-
// (start=`. Every pulse therefore fell through to "no earlier launch is recorded in the log
|
|
702
|
-
// tail" and the previous shell's lifetime was never reported. MEASURED on desktop-14rjcr8 —
|
|
703
|
-
// the pulse said exactly that at 18:06:53 while the launching line it was looking for sat
|
|
704
|
-
// thirty lines above it in the same file. The ` .*` is the fix, and test/cli.test.mjs now
|
|
705
|
-
// runs THIS pattern against a REAL captured line, which is the check that would have caught it.
|
|
706
|
-
"$prev = $tail | Select-String -Pattern 'desktop: electron.exe launching .*\\(start=(\\d+)\\)' | Select-Object -Last 1",
|
|
707
|
-
"if ($prev -and ($prev.Line -match '\\(start=(\\d+)\\)')) {",
|
|
708
|
-
" $E = [int]$Matches[1]",
|
|
709
|
-
" if ($tail -match ('desktop: electron\\.exe exited \\(start=' + $E + ' ')) { $was = 'the previous shell exited and its code is in the exited (start=' + $E + ') line' }",
|
|
710
|
-
" else { $was = 'the previous shell lived ' + ([int]$now - $E) + 's and left NO exit line -- it was KILLED, not exited: a Task Scheduler stop, a taskkill /T or a reboot leaves no exit code (start=' + $E + ')' }",
|
|
711
|
-
"}",
|
|
712
|
-
"Add-Content -Path $log -Value ('[' + (Get-Date -Format o) + '] desktop: no electron shell is running -- the watchdog is starting it; previous: ' + $was) -ErrorAction SilentlyContinue",
|
|
713
|
-
`& powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptsQ}\\start-desktop.ps1"`,
|
|
714
|
-
];
|
|
715
|
-
}
|
|
716
|
-
/** The desktop shell's watchdog pair + its task, as ONE PowerShell script.
|
|
717
|
-
*
|
|
718
|
-
* WHY IT IS A FILE AND NOT AN INLINE -Command: it has to write two other files whose
|
|
719
|
-
* contents contain quotes, and every attempt to do that inside one command string turns
|
|
720
|
-
* into quoting hell — the first version of this shipped as a hand-written file for that
|
|
721
|
-
* reason. `summrise setup` writes it and `summrise desktop` runs it, so a machine that
|
|
722
|
-
* never ran setup can still get a working task by asking for the window. Idempotent:
|
|
723
|
-
* ensure-desktop.ps1 exits when electron is already alive, so the 5-minute trigger never
|
|
724
|
-
* steals focus. unit-tested. */
|
|
725
|
-
function desktopTaskPs(installQ, logsQ) {
|
|
726
|
-
return [
|
|
727
|
-
"# written by `summrise setup` / `summrise desktop` -- the desktop shell's task + watchdog.",
|
|
728
|
-
"$ErrorActionPreference = 'Stop'",
|
|
729
|
-
`$q = '${installQ}'`,
|
|
730
|
-
`$en = Join-Path $q 'scripts\\ensure-desktop.ps1'`,
|
|
731
|
-
`$vb = Join-Path $q 'scripts\\desktop-pulse.vbs'`,
|
|
732
|
-
// THE WATCHDOG'S BODY COMES FROM ITS OWN BUILDER, not from a second hand-typed copy of
|
|
733
|
-
// the same one-liner. Three paths write this file — `summrise setup`, `summrise desktop`
|
|
734
|
-
// and the update swap — and a copy that drifts is a copy that silently reverts the
|
|
735
|
-
// shell's record on whichever path was not edited. A single-quoted here-string
|
|
736
|
-
// (`@'` … `'@`, each alone on its line) carries it byte for byte: nothing in the body is
|
|
737
|
-
// expanded, and nothing in the body can end it early.
|
|
738
|
-
`$enBody = @'`,
|
|
739
|
-
...ensureDesktopPs(`${installQ}\\scripts`, logsQ),
|
|
740
|
-
`'@`,
|
|
741
|
-
`Set-Content -Path $en -Value $enBody -Force`,
|
|
742
|
-
`Set-Content -Path $vb -Value ('CreateObject("WScript.Shell").Run "powershell -NoProfile -ExecutionPolicy Bypass -File " & Chr(34) & "' + $en + '" & Chr(34), 0, False') -Force`,
|
|
743
|
-
`$da = New-ScheduledTaskAction -Execute 'wscript.exe' -Argument ('"' + $vb + '"') -WorkingDirectory $q`,
|
|
744
|
-
`$dt1 = New-ScheduledTaskTrigger -AtLogOn`,
|
|
745
|
-
`$dw1 = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -RepetitionInterval (New-TimeSpan -Minutes 5)`,
|
|
746
|
-
`$pr = New-ScheduledTaskPrincipal -UserId ('{0}\\{1}' -f $env:USERDOMAIN, $env:USERNAME) -LogonType Interactive -RunLevel Highest`,
|
|
747
|
-
// NO EXECUTION TIME LIMIT -- the same value, in the same spelling, the agent's own task has
|
|
748
|
-
// carried since round 118 for the same reason: `- ExecutionTimeLimit 0 never kill the
|
|
749
|
-
// running task`. It was `-Minutes 10` here, and that WAS the defect.
|
|
750
|
-
//
|
|
751
|
-
// WHY A LIMIT KILLS A SHELL MEANT TO LIVE INDEFINITELY: this task's action is
|
|
752
|
-
// desktop-pulse.vbs -> ensure-desktop.ps1 -> start-desktop.ps1, and that last line is
|
|
753
|
-
// `& electron.exe .` -- the PowerShell CALL OPERATOR, which WAITS. The PowerShell the task
|
|
754
|
-
// launched therefore stays alive for as long as the shell does, as the shell's PARENT, and
|
|
755
|
-
// Task Scheduler enforces ExecutionTimeLimit on that process tree. Ten minutes after every
|
|
756
|
-
// start the scheduler killed the running shell; five minutes later the guarded pulse
|
|
757
|
-
// correctly brought a fresh one back; ten minutes after that it died again. The operator
|
|
758
|
-
// reported that as "反复重启", four times.
|
|
759
|
-
//
|
|
760
|
-
// NOTHING RELIED ON THE KILL, which is why removing it is the fix rather than a trade. This
|
|
761
|
-
// repository's recovery for a shell that is GONE is the guarded 5-minute pulse, and
|
|
762
|
-
// summrise-desktop-electron/src/main.ts says so from the other side, naming a logon-only task
|
|
763
|
-
// as one that "cannot recover a wedged shell". A shell that is RUNNING but wedged was never
|
|
764
|
-
// this limit's business either: the pulse's guard is a process-EXISTENCE check
|
|
765
|
-
// (`if (Get-Process electron) { exit }`), so it cannot see a wedged shell whether the limit is
|
|
766
|
-
// ten minutes or absent. `summrise update` is what heals a wedged shell, and its log says so.
|
|
767
|
-
//
|
|
768
|
-
// Measured on desktop-14rjcr8: under PT10M the four electron PIDs were replaced about every
|
|
769
|
-
// ten minutes; under PT0S the same four PIDs were still there 11 minutes later.
|
|
770
|
-
`$st = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew`,
|
|
771
|
-
`Register-ScheduledTask SummriseDesktop -Action $da -Trigger @($dt1,$dw1) -Principal $pr -Settings $st -Force | Out-Null`,
|
|
772
|
-
`Start-ScheduledTask -TaskName SummriseDesktop`,
|
|
773
|
-
];
|
|
774
|
-
}
|
|
775
|
-
/** The desktop shell's START script, as a file — NOT a `-Command` string.
|
|
776
|
-
*
|
|
777
|
-
* WHY A FILE, and this is the third thing the device taught us: the CLI spawns with
|
|
778
|
-
* `shell: true`, so every command goes through cmd.exe, and **cmd treats `&` as a command
|
|
779
|
-
* separator**. The PowerShell call operator `&` therefore split the command in half, the
|
|
780
|
-
* `$t` assignment never ran, and the command fell through to re-registering the task —
|
|
781
|
-
* which then failed on the service account's principal. One metacharacter, two unrelated
|
|
782
|
-
* looking failures. `powershell -File "<path>"` has no metacharacters to mangle, and the
|
|
783
|
-
* operator can READ what the command does.
|
|
784
|
-
*
|
|
785
|
-
* Prints ONE WORD the CLI can trust: `already-running`, `started`, or `not-started`.
|
|
786
|
-
* Pure; the CLI writes it and runs it. unit-tested. */
|
|
787
|
-
function desktopStartPs(installQ) {
|
|
788
|
-
return [
|
|
789
|
-
"# written by `summrise desktop` -- ask for the window, or say it is already there.",
|
|
790
|
-
"$ErrorActionPreference = 'Stop'",
|
|
791
|
-
"if (Get-Process electron -ErrorAction SilentlyContinue) { Write-Output 'already-running'; exit 0 }",
|
|
792
|
-
"# DO NOT RE-REGISTER THE TASK TO START IT. Register-ScheduledTask needs the interactive",
|
|
793
|
-
"# user's principal as DOMAIN\\user, and a shell running as a service account has no such",
|
|
794
|
-
'# mapping -- the device answered "No mapping between account names and security IDs was',
|
|
795
|
-
'# done ... UserId" from a PTY running as systemprofile, while the task itself was Ready.',
|
|
796
|
-
"# The task already carries the right principal -- `summrise setup` creates it -- so starting",
|
|
797
|
-
"# it is all that is needed.",
|
|
798
|
-
"$t = Get-ScheduledTask -TaskName SummriseDesktop -ErrorAction SilentlyContinue",
|
|
799
|
-
`if (-not $t) { & powershell -NoProfile -ExecutionPolicy Bypass -File "${installQ}\\scripts\\register-desktop-task.ps1" }`,
|
|
800
|
-
"Start-ScheduledTask -TaskName SummriseDesktop -ErrorAction SilentlyContinue",
|
|
801
|
-
"Start-Sleep -Seconds 3",
|
|
802
|
-
"if (Get-Process electron -ErrorAction SilentlyContinue) { Write-Output 'started' } else { Write-Output 'not-started'; exit 1 }",
|
|
803
|
-
];
|
|
804
|
-
}
|
|
805
|
-
// exported: agent bind port plumbing (custom-port installs). server.port
|
|
806
|
-
// out of <dir>/config.yaml (first `port:` under top-level `server:`),
|
|
807
|
-
// canonical 18080 when absent/invalid/missing (fresh installs have no
|
|
808
|
-
// config yet — the agent writes defaults on first boot). Pure core
|
|
809
|
-
// unit-tested; agentPort() is the thin fs wrapper. unit-tested in
|
|
810
|
-
// test/cli.test.mjs.
|
|
811
|
-
function parseAgentPort(yamlText) {
|
|
812
|
-
let inServer = false;
|
|
813
|
-
for (const raw of String(yamlText || "").split(/\r?\n/)) {
|
|
814
|
-
const line = raw.replace(/\s+$/, "");
|
|
815
|
-
if (/^\S/.test(line))
|
|
816
|
-
inServer = /^server\s*:/.test(line);
|
|
817
|
-
if (!inServer)
|
|
818
|
-
continue;
|
|
819
|
-
const m = /^\s*port\s*:\s*"?(\d{1,5})"?\s*(#.*)?$/.exec(line);
|
|
820
|
-
if (m) {
|
|
821
|
-
const n = Number(m[1]);
|
|
822
|
-
return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
return null;
|
|
826
|
-
}
|
|
827
|
-
// The device token, read from etc\config.yaml the same way the port is: a line
|
|
828
|
-
// scan, no YAML dependency. Absent means "cannot talk to the device API", which
|
|
829
|
-
// the callers report as a state rather than as a crash.
|
|
830
|
-
function parseDeviceToken(yamlText) {
|
|
831
|
-
let inServer = false;
|
|
832
|
-
for (const raw of String(yamlText || "").split(/\r?\n/)) {
|
|
833
|
-
const line = raw.replace(/\s+$/, "");
|
|
834
|
-
if (/^\S/.test(line))
|
|
835
|
-
inServer = /^server\s*:/.test(line);
|
|
836
|
-
if (!inServer)
|
|
837
|
-
continue;
|
|
838
|
-
const m = /^\s*device_token\s*:\s*"?([A-Za-z0-9._-]+)"?\s*(#.*)?$/.exec(line);
|
|
839
|
-
if (m)
|
|
840
|
-
return m[1];
|
|
841
|
-
}
|
|
842
|
-
return null;
|
|
843
|
-
}
|
|
844
|
-
function deviceToken(dir) {
|
|
845
|
-
try {
|
|
846
|
-
return parseDeviceToken(fs.readFileSync(path.join(dir, "config.yaml"), "utf8"));
|
|
847
|
-
}
|
|
848
|
-
catch {
|
|
849
|
-
return null;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
// `host:port[/path]` -> the target id the device uses. A path is part of the
|
|
853
|
-
// identity, so `192.168.1.1:80/` and `192.168.1.1:80` are two different checks.
|
|
854
|
-
function parseTargetArg(arg) {
|
|
855
|
-
const s = String(arg || "").trim();
|
|
856
|
-
const m = /^([^\s/:]+):(\d{1,5})(\/.*)?$/.exec(s);
|
|
857
|
-
if (!m)
|
|
858
|
-
return null;
|
|
859
|
-
const port = Number(m[2]);
|
|
860
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
861
|
-
return null;
|
|
862
|
-
return {
|
|
863
|
-
host: m[1],
|
|
864
|
-
port,
|
|
865
|
-
path: m[3] || "",
|
|
866
|
-
id: `${m[1]}:${port}${m[3] || ""}`,
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
/**
|
|
870
|
-
* JSON that survives the command line.
|
|
871
|
-
*
|
|
872
|
-
* `deviceApi` hands its body to `curl -d <string>`, and on Windows that argument is encoded in the
|
|
873
|
-
* process's ANSI code page — NOT UTF-8. So a note typed with an em dash reached the device as
|
|
874
|
-
* mojibake: the CLI echoed back what it had been GIVEN ("— I rebooted it"), while the stored copy
|
|
875
|
-
* read `鈥?` everywhere the device's own data was shown (list, report, --json). Caught on d1 by
|
|
876
|
-
* writing a note with a dash in it and reading it back from the device.
|
|
877
|
-
*
|
|
878
|
-
* Escaping every non-ASCII character as \uXXXX makes the body pure ASCII, which no code page can
|
|
879
|
-
* mangle, and `JSON.parse` on the device restores the original text exactly.
|
|
880
|
-
*/
|
|
881
|
-
function asciiJson(value, indent) {
|
|
882
|
-
return JSON.stringify(value, null, indent).replace(/[\u007f-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
883
|
-
}
|
|
884
|
-
// The device's own API on loopback, with the token from etc\config.yaml.
|
|
885
|
-
function deviceApi(method, pathname, body) {
|
|
886
|
-
const dir = ETC_DIR;
|
|
887
|
-
const token = deviceToken(dir);
|
|
888
|
-
if (!token)
|
|
889
|
-
return {
|
|
890
|
-
ok: false,
|
|
891
|
-
error: "no device token in " + path.join(dir, "config.yaml"),
|
|
892
|
-
};
|
|
893
|
-
const port = agentPort(dir);
|
|
894
|
-
const args = [
|
|
895
|
-
"-sS",
|
|
896
|
-
"-m",
|
|
897
|
-
"15",
|
|
898
|
-
"-X",
|
|
899
|
-
method,
|
|
900
|
-
"-H",
|
|
901
|
-
"Authorization: Bearer " + token,
|
|
902
|
-
];
|
|
903
|
-
if (body !== undefined) {
|
|
904
|
-
args.push("-H", "content-type: application/json", "-d", asciiJson(body));
|
|
905
|
-
}
|
|
906
|
-
args.push(`http://127.0.0.1:${port}${pathname}`);
|
|
907
|
-
const r = (0, child_process_1.spawnSync)("curl", args, { encoding: "utf8", timeout: 20000 });
|
|
908
|
-
if (r.error || r.status !== 0) {
|
|
909
|
-
return {
|
|
910
|
-
ok: false,
|
|
911
|
-
error: `device unreachable on 127.0.0.1:${port}` +
|
|
912
|
-
(r.error ? ` (${r.error.message})` : ""),
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
try {
|
|
916
|
-
return { ok: true, body: JSON.parse(String(r.stdout || "").trim()) };
|
|
917
|
-
}
|
|
918
|
-
catch {
|
|
919
|
-
return { ok: false, error: "device sent something that is not JSON" };
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
/**
|
|
923
|
-
* A duration in the shapes the rest of the panel uses: `45s`, `4m`, `1h 04m`, `2d 4h`.
|
|
924
|
-
*
|
|
925
|
-
* IT USED TO CARRY FOURTEEN LINES ABOUT `summrise report`, a verb pruned in round 25 — including "THE
|
|
926
|
-
* RENDER IS PURE (`reportText`)", naming a function that occurs nowhere else in this file. The doc
|
|
927
|
-
* described a command an operator cannot run, on a function whose actual job went unsaid. Two rules from
|
|
928
|
-
* that block are worth keeping, and this is not the place they belong: a server that did not answer is
|
|
929
|
-
* printed as NOT READ, never as an invented value, and a target that is down brings its outage log.
|
|
930
|
-
*/
|
|
931
|
-
function fmtDuration(ms) {
|
|
932
|
-
const s = Math.max(0, Math.floor(Number(ms || 0) / 1000));
|
|
933
|
-
if (s < 60)
|
|
934
|
-
return `${s}s`;
|
|
935
|
-
if (s < 3600)
|
|
936
|
-
return `${Math.floor(s / 60)}m`;
|
|
937
|
-
const h = Math.floor(s / 3600);
|
|
938
|
-
return h >= 24
|
|
939
|
-
? `${Math.floor(h / 24)}d ${h % 24}h`
|
|
940
|
-
: `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
|
|
941
|
-
}
|
|
942
|
-
function targetLine(t, nowMs, width = 30) {
|
|
943
|
-
const s = t.summary || {};
|
|
944
|
-
const up = s.up_now === true;
|
|
945
|
-
const state = up ? "up" : s.up_now === false ? "down" : "no readings";
|
|
946
|
-
const id = String(t.id || "");
|
|
947
|
-
const since = s.since_ms ? ` ${fmtDuration(nowMs - s.since_ms)}` : "";
|
|
948
|
-
const status = s.last_status === null || s.last_status === undefined
|
|
949
|
-
? ""
|
|
950
|
-
: ` HTTP ${s.last_status}`;
|
|
951
|
-
// A content check that did not find its text is the case a status code cannot express, so it
|
|
952
|
-
// is printed as its own word next to the code.
|
|
953
|
-
const match = s.last_expect_ok === false
|
|
954
|
-
? " no match"
|
|
955
|
-
: s.last_expect_ok === true
|
|
956
|
-
? " matches"
|
|
957
|
-
: "";
|
|
958
|
-
const lat = s.latency ? ` ${s.latency.avg}ms avg` : "";
|
|
959
|
-
const pct = s.up_pct === null || s.up_pct === undefined ? "" : ` ${s.up_pct}% up`;
|
|
960
|
-
const drops = s.drops
|
|
961
|
-
? ` ${s.drops} ${s.drops === 1 ? "drop" : "drops"}`
|
|
962
|
-
: "";
|
|
963
|
-
// The operator's own words belong on the line they explain, not in a separate view.
|
|
964
|
-
const note = t.note && t.note.text ? ` — ${t.note.text}` : "";
|
|
965
|
-
return `${up ? "UP " : s.up_now === false ? "DOWN" : "? "} ${id.padEnd(width)} ${state}${since}${status}${match}${lat}${pct}${drops}${note}`;
|
|
966
|
-
}
|
|
967
|
-
// ── MACHINE-READABLE OUTPUT ────────────────────────────────────────────────
|
|
968
|
-
// `summrise monitor list --json` prints the DEVICE'S OWN ANSWER,
|
|
969
|
-
// projected rather than re-derived: the summaries, the transitions and the samples are the ones
|
|
970
|
-
// `/api/monitors` returns, so a script and the panel (and the AI) can never disagree about whether
|
|
971
|
-
// a target is up. The CLI adds only what the device cannot know — which device answered, and when
|
|
972
|
-
// this script asked — under names that say so.
|
|
973
|
-
//
|
|
974
|
-
// A STABLE SHAPE, not a passthrough: `{device, asked_at_ms, interval_secs, targets:[…]}`. Anything
|
|
975
|
-
// a script needs to branch on is in it; anything that is presentation (the coloured line, the
|
|
976
|
-
// outage wording) is not.
|
|
977
|
-
function monitorsJson({ device, askedAtMs, payload, only }) {
|
|
978
|
-
const targets = ((payload && payload.targets) || [])
|
|
979
|
-
.filter((t) => !only || t.id === only)
|
|
980
|
-
.map((t) => ({
|
|
981
|
-
// EVERY field is present in every row, as `null` when the device did not send it: a
|
|
982
|
-
// field that vanishes from the JSON is a field a script cannot tell from `false`, and
|
|
983
|
-
// `JSON.stringify` drops `undefined` keys silently (which is how the first version of
|
|
984
|
-
// this shipped `up_pct` missing for a target that had never been probed).
|
|
985
|
-
id: t.id === undefined ? null : t.id,
|
|
986
|
-
host: t.host === undefined ? null : t.host,
|
|
987
|
-
port: t.port === undefined ? null : t.port,
|
|
988
|
-
path: t.path === undefined ? null : t.path,
|
|
989
|
-
expect: t.expect === undefined ? null : t.expect,
|
|
990
|
-
// The device's numbers, verbatim. `up: null` means "not read yet" and is NOT false.
|
|
991
|
-
up: t.summary && t.summary.up_now !== undefined ? t.summary.up_now : null,
|
|
992
|
-
up_pct: t.summary && t.summary.up_pct !== undefined ? t.summary.up_pct : null,
|
|
993
|
-
since_ms: t.summary && t.summary.since_ms !== undefined
|
|
994
|
-
? t.summary.since_ms
|
|
995
|
-
: null,
|
|
996
|
-
drops: t.summary && t.summary.drops !== undefined ? t.summary.drops : null,
|
|
997
|
-
latency_ms: t.summary && t.summary.latency ? t.summary.latency.avg : null,
|
|
998
|
-
last_status: t.summary && t.summary.last_status !== undefined
|
|
999
|
-
? t.summary.last_status
|
|
1000
|
-
: null,
|
|
1001
|
-
last_expect_ok: t.summary && t.summary.last_expect_ok !== undefined
|
|
1002
|
-
? t.summary.last_expect_ok
|
|
1003
|
-
: null,
|
|
1004
|
-
probes: t.summary && t.summary.probes !== undefined ? t.summary.probes : null,
|
|
1005
|
-
transitions: (t.transitions || []).map((x) => ({
|
|
1006
|
-
at_ms: x.at_ms,
|
|
1007
|
-
up: x.up,
|
|
1008
|
-
lasted_ms: x.lasted_ms,
|
|
1009
|
-
})),
|
|
1010
|
-
}));
|
|
1011
|
-
return {
|
|
1012
|
-
device: device || "",
|
|
1013
|
-
asked_at_ms: askedAtMs,
|
|
1014
|
-
interval_secs: (payload && payload.interval_secs) || null,
|
|
1015
|
-
targets,
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
// ONE PROBE'S ANSWER, as a line — the terminal's version of what the device's `monitor_probe`
|
|
1019
|
-
// returns. Pure, because the wording is the feature: a DOWN probe says WHICH way it failed
|
|
1020
|
-
// (no connection / status 500 / 200 without the expected text), not merely that it failed.
|
|
1021
|
-
function probeLine(target, probe, nowMs) {
|
|
1022
|
-
const id = String((target && target.id) || "");
|
|
1023
|
-
const ok = probe && probe.ok === true;
|
|
1024
|
-
const state = ok ? "UP " : "DOWN";
|
|
1025
|
-
const bits = [];
|
|
1026
|
-
if (probe && probe.status !== null && probe.status !== undefined)
|
|
1027
|
-
bits.push(`HTTP ${probe.status}`);
|
|
1028
|
-
// A content check is the one failure a status code cannot express, so it is named.
|
|
1029
|
-
if (target && target.expect) {
|
|
1030
|
-
if (probe && probe.expect_ok === false)
|
|
1031
|
-
bits.push(`no match for "${target.expect}"`);
|
|
1032
|
-
else if (probe && probe.expect_ok === true)
|
|
1033
|
-
bits.push(`matches "${target.expect}"`);
|
|
1034
|
-
else
|
|
1035
|
-
bits.push(`could not read the body to look for "${target.expect}"`);
|
|
1036
|
-
}
|
|
1037
|
-
if (probe && typeof probe.ms === "number")
|
|
1038
|
-
bits.push(`${probe.ms}ms`);
|
|
1039
|
-
if (!ok && bits.length === 0)
|
|
1040
|
-
bits.push("no answer");
|
|
1041
|
-
return `${state} ${id}${bits.length ? " " + bits.join(" ") : ""}`;
|
|
1042
|
-
}
|
|
1043
|
-
// The transitions a target has been through, newest first — the outage log an
|
|
1044
|
-
// operator pastes into a report.
|
|
1045
|
-
function agentPort(dir) {
|
|
1046
|
-
try {
|
|
1047
|
-
return (parseAgentPort(fs.readFileSync(path.join(dir, "config.yaml"), "utf8")) ??
|
|
1048
|
-
18080);
|
|
1049
|
-
}
|
|
1050
|
-
catch {
|
|
1051
|
-
return 18080;
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
// exported: idempotent Windows firewall inbound rule for the agent port
|
|
1055
|
-
// (LAN clients are dropped at the firewall otherwise, even bound 0.0.0.0).
|
|
1056
|
-
// ASCII-only PS, plain statements (WMI/session-0 rule). Prunes our own
|
|
1057
|
-
// stale-port rules, never foreign ones (DisplayName-scoped). unit-tested.
|
|
1058
|
-
function firewallPs(port) {
|
|
1059
|
-
return [
|
|
1060
|
-
`$fwPort = ${port};`,
|
|
1061
|
-
`foreach ($fr in @(Get-NetFirewallRule -DisplayName 'Summrise Agent' -ErrorAction SilentlyContinue)) { try { $fp = @(Get-NetFirewallPortFilter -AssociatedNetFirewallRule $fr | Select-Object -ExpandProperty LocalPort); if ($fp -notcontains "$fwPort") { Remove-NetFirewallRule -Name $fr.Name -Confirm:$false -ErrorAction SilentlyContinue } } catch {} }`,
|
|
1062
|
-
`if (-not (Get-NetFirewallRule -DisplayName 'Summrise Agent' -ErrorAction SilentlyContinue | Where-Object { @(Get-NetFirewallPortFilter -AssociatedNetFirewallRule $PSItem | Select-Object -ExpandProperty LocalPort) -contains "$fwPort" })) { New-NetFirewallRule -DisplayName 'Summrise Agent' -Direction Inbound -LocalPort $fwPort -Protocol TCP -Action Allow | Out-Null }`,
|
|
1063
|
-
];
|
|
1064
|
-
}
|
|
1065
|
-
// exported: SummriseAgent boot-task registration (SYSTEM, hardened). The task's
|
|
1066
|
-
// -Argument is the EXPLICIT config path (layout v2: etc\config.yaml) — never
|
|
1067
|
-
// the exe path (Rust takes argv[1] as the config FILE; an exe path fails
|
|
1068
|
-
// YAML parse and quarantines the install). Shared by setup (fresh install)
|
|
1069
|
-
// and the update swap (repoint, fail-closed — the repoint runs BEFORE any
|
|
1070
|
-
// swap, and a config-path argument boots old AND new agents alike, so a
|
|
1071
|
-
// repoint failure aborts the update with the old version still running).
|
|
1072
|
-
// `start` appends the kick for setup; the swap omits it (it restarts the
|
|
1073
|
-
// task itself after the swap). ASCII-only PS. unit-tested.
|
|
1074
|
-
function bootTaskPs(exeQ, cfgQ, start = false) {
|
|
1075
|
-
const lines = [
|
|
1076
|
-
`$action = New-ScheduledTaskAction -Execute '${exeQ}' -Argument ('"' + '${cfgQ}' + '"')`,
|
|
1077
|
-
"$boot = New-ScheduledTaskTrigger -AtStartup",
|
|
1078
|
-
"$watch = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -RepetitionInterval (New-TimeSpan -Minutes 5)",
|
|
1079
|
-
"$principal = New-ScheduledTaskPrincipal -UserId SYSTEM -LogonType ServiceAccount -RunLevel Highest",
|
|
1080
|
-
"$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -RestartCount 8 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable",
|
|
1081
|
-
"Register-ScheduledTask SummriseAgent -Action $action -Trigger @($boot,$watch) -Principal $principal -Settings $settings -Force | Out-Null",
|
|
1082
|
-
];
|
|
1083
|
-
if (start)
|
|
1084
|
-
lines.push("Start-ScheduledTask SummriseAgent");
|
|
1085
|
-
return lines;
|
|
1086
|
-
}
|
|
1087
|
-
// exported: layout-v2 one-time migration (ADR 0008) for the setup/update
|
|
1088
|
-
// paths. Mirrors paths.rs migration_moves EXACTLY (same pairs — both sides
|
|
1089
|
-
// pinned by tests; drift strands upgraded devices). Moves only when the
|
|
1090
|
-
// target is missing (never clobbers staged .new output); dirs merge
|
|
1091
|
-
// children. Best-effort per item; callers GATE on etc\config.yaml +
|
|
1092
|
-
// hostname afterwards (fail-closed). Marker aging (same contract as
|
|
1093
|
-
// paths.rs): skip entirely when etc\.layout-v2 exists; write it only when
|
|
1094
|
-
// nothing is pending (old home exists, new missing). q = escaped DIR,
|
|
1095
|
-
// dq = escaped DataDir. ASCII-only PS. unit-tested.
|
|
1096
|
-
function migrateLayoutPs(q, dq) {
|
|
1097
|
-
const mvf = (oldRel, newAbs) => `if ($summriseMg -and (Test-Path '${q}\\${oldRel}') -and (-not (Test-Path '${newAbs}'))) { try { New-Item -ItemType Directory -Force -Path (Split-Path '${newAbs}') | Out-Null; Move-Item -Force -Path '${q}\\${oldRel}' -Destination '${newAbs}' -ErrorAction Stop } catch {} }`;
|
|
1098
|
-
const mvd = (oldRel, newAbs) => `if ($summriseMg -and (Test-Path '${q}\\${oldRel}')) { try { if (-not (Test-Path '${newAbs}')) { New-Item -ItemType Directory -Force -Path (Split-Path '${newAbs}') | Out-Null; Move-Item -Path '${q}\\${oldRel}' -Destination '${newAbs}' -ErrorAction Stop } else { Get-ChildItem -Force '${q}\\${oldRel}' | ForEach-Object { if (-not (Test-Path (Join-Path '${newAbs}' $_.Name))) { Move-Item -Force -Path $_.FullName -Destination (Join-Path '${newAbs}' $_.Name) -ErrorAction SilentlyContinue } } } } catch {} }`;
|
|
1099
|
-
const etc = `${q}\\etc`;
|
|
1100
|
-
const comp = `${q}\\components`;
|
|
1101
|
-
const scr = `${q}\\scripts`;
|
|
1102
|
-
const logs = `${dq}\\logs`;
|
|
1103
|
-
// (oldRel, newAbs, kind) pairs — 'f' files move atomically, 'd' dirs
|
|
1104
|
-
// merge children. The pending check covers EVERY pair exactly once with
|
|
1105
|
-
// paths.rs' rule (old && !new); a merged dir whose target EXISTS is not
|
|
1106
|
-
// pending even with locked leftovers behind them (garbage for uninstall).
|
|
1107
|
-
const moves = [
|
|
1108
|
-
["config.yaml", `${etc}\\config.yaml`, "f"],
|
|
1109
|
-
["summrise-agent.hostname", `${etc}\\summrise-agent.hostname`, "f"],
|
|
1110
|
-
["tunnel.yml", `${etc}\\tunnel.yml`, "f"],
|
|
1111
|
-
[".summrise-release", `${etc}\\.summrise-release`, "f"],
|
|
1112
|
-
["boxed-versions.json", `${etc}\\boxed-versions.json`, "f"],
|
|
1113
|
-
["tools\\node", `${comp}\\node`, "d"],
|
|
1114
|
-
["tools\\npm-global", `${comp}\\npm-global`, "d"],
|
|
1115
|
-
["tools\\cloudflared.exe", `${comp}\\cloudflared.exe`, "f"],
|
|
1116
|
-
["playwright", `${comp}\\playwright`, "d"],
|
|
1117
|
-
["summrise-desktop-electron", `${comp}\\summrise-desktop-electron`, "d"],
|
|
1118
|
-
["ensure-desktop.ps1", `${scr}\\ensure-desktop.ps1`, "f"],
|
|
1119
|
-
["desktop-pulse.vbs", `${scr}\\desktop-pulse.vbs`, "f"],
|
|
1120
|
-
["start-desktop.ps1", `${scr}\\start-desktop.ps1`, "f"],
|
|
1121
|
-
["summrise-online-setup.ps1", `${scr}\\summrise-online-setup.ps1`, "f"],
|
|
1122
|
-
["fix-tunnel.ps1", `${scr}\\fix-tunnel.ps1`, "f"],
|
|
1123
|
-
["playwright\\run-hidden.vbs", `${scr}\\run-hidden.vbs`, "f"],
|
|
1124
|
-
["playwright\\playwright-probe.ps1", `${scr}\\playwright-probe.ps1`, "f"],
|
|
1125
|
-
["shell-integration", `${scr}\\shell-integration`, "d"],
|
|
1126
|
-
["installer.log", `${logs}\\installer.log`, "f"],
|
|
1127
|
-
["install-result.txt", `${logs}\\install-result.txt`, "f"],
|
|
1128
|
-
["summrise-update.log", `${logs}\\summrise-update.log`, "f"],
|
|
1129
|
-
["agent.log", `${logs}\\agent.log`, "f"],
|
|
1130
|
-
["startup.log", `${logs}\\startup.log`, "f"],
|
|
1131
|
-
["pwout", `${dq}\\pwout`, "d"],
|
|
1132
|
-
];
|
|
1133
|
-
const pending = moves
|
|
1134
|
-
.map(([o, n]) => `((Test-Path '${q}\\${o}') -and (-not (Test-Path '${n}')))`)
|
|
1135
|
-
.join(" -or ");
|
|
1136
|
-
// All statements stay SINGLE-LINE (setup passes them joined with "; "
|
|
1137
|
-
// through -Command): the marker guard is precomputed into $summriseMg and
|
|
1138
|
-
// every line carries it, instead of wrapping the block in braces.
|
|
1139
|
-
return [
|
|
1140
|
-
`$summriseMg = (-not (Test-Path '${etc}\\.layout-v2'))`,
|
|
1141
|
-
// A running boxed node locks the playwright tree — stop DIR-local ones
|
|
1142
|
-
// first (setup precedent; the updater itself runs from npm-global,
|
|
1143
|
-
// which never matches the playwright filter).
|
|
1144
|
-
`if ($summriseMg) { Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -like '*${q}*playwright*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } }`,
|
|
1145
|
-
...moves.map(([o, n, k]) => (k === "d" ? mvd(o, n) : mvf(o, n))),
|
|
1146
|
-
`if ($summriseMg -and (-not (${pending}))) { try { New-Item -ItemType Directory -Force -Path '${etc}' | Out-Null; New-Item -ItemType File -Force -Path '${etc}\\.layout-v2' | Out-Null } catch {} }`,
|
|
1147
|
-
];
|
|
1148
|
-
}
|
|
1149
|
-
// exported: Add/Remove-Programs version parity. The NSIS installer writes
|
|
1150
|
-
// DisplayVersion once at install time, but `summrise update` swaps the exe
|
|
1151
|
-
// out-of-band — without this the control-panel entry shows the ORIGINAL
|
|
1152
|
-
// version forever and misleads troubleshooting. Same round-298 discipline
|
|
1153
|
-
// as .summrise-release: the caller splices these lines right after the marker
|
|
1154
|
-
// write, gated on $ok (a failed swap must not move the version), wrapped
|
|
1155
|
-
// in try/catch (best-effort — a registry failure must never fail the
|
|
1156
|
-
// update). Creates the key when missing (npm-only installs never had one)
|
|
1157
|
-
// but never fabricates UninstallString (NSIS owns it; npm uninstall is
|
|
1158
|
-
// `summrise uninstall`). q = single-quote-escaped install dir, ver = release
|
|
1159
|
-
// version. ASCII-only PS. unit-tested.
|
|
1160
|
-
// exported: Add/Remove-Programs entry body (shared by setup + the update
|
|
1161
|
-
// swap). Writes DisplayVersion/DisplayName/InstallLocation/Publisher always;
|
|
1162
|
-
// writes UninstallString ONLY when absent — NSIS installs own theirs
|
|
1163
|
-
// ($INSTDIR\uninstall.exe) and it must never be overwritten. The fallback
|
|
1164
|
-
// value relaunches summrise.cmd ELEVATED (control panel does not elevate for
|
|
1165
|
-
// us; without RunAs the uninstall dies on HKLM/schtasks with access
|
|
1166
|
-
// denied), preferring components\npm-global (layout v2) then the legacy
|
|
1167
|
-
// tools\ path. Best-effort try/catch throughout — a registry failure must
|
|
1168
|
-
// never fail install/update. Empty ver = no-op. ASCII-only PS. unit-tested.
|
|
1169
|
-
function uninstallRegBodyPs(q, ver) {
|
|
1170
|
-
if (!ver)
|
|
1171
|
-
return [];
|
|
1172
|
-
return [
|
|
1173
|
-
`try {`,
|
|
1174
|
-
` $rk = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\SummriseAgent'`,
|
|
1175
|
-
` if (-not (Test-Path $rk)) { New-Item -Path $rk -Force | Out-Null }`,
|
|
1176
|
-
` Set-ItemProperty -Path $rk -Name DisplayVersion -Value '${ver}' -ErrorAction Stop`,
|
|
1177
|
-
` Set-ItemProperty -Path $rk -Name DisplayName -Value 'Summrise Agent ${ver}' -ErrorAction Stop`,
|
|
1178
|
-
` Set-ItemProperty -Path $rk -Name InstallLocation -Value '${q}' -ErrorAction Stop`,
|
|
1179
|
-
` Set-ItemProperty -Path $rk -Name Publisher -Value 'Summrise' -ErrorAction Stop`,
|
|
1180
|
-
` $uv = '${q}\\components\\npm-global\\summrise.cmd'`,
|
|
1181
|
-
` if (-not (Test-Path $uv)) { $uv = '${q}\\tools\\npm-global\\summrise.cmd' }`,
|
|
1182
|
-
` if ((Test-Path $uv) -and (-not (Get-ItemProperty -Path $rk -Name UninstallString -ErrorAction SilentlyContinue))) { Set-ItemProperty -Path $rk -Name UninstallString -Value ('powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath ''' + $uv + ''' -ArgumentList ''uninstall'' -Verb RunAs -Wait"') -ErrorAction Stop }`,
|
|
1183
|
-
`} catch {}`,
|
|
1184
|
-
];
|
|
1185
|
-
}
|
|
1186
|
-
function uninstallVersionPs(q, ver) {
|
|
1187
|
-
if (!ver)
|
|
1188
|
-
return [];
|
|
1189
|
-
return [`if ($ok -and '${ver}') {`, ...uninstallRegBodyPs(q, ver), `}`];
|
|
1190
|
-
}
|
|
1191
|
-
// exported: autostart (boot) switch for the two scheduled tasks. SummriseAgent
|
|
1192
|
-
// (SYSTEM service task) + SummriseDesktop (logon shell task) ARE the autostart
|
|
1193
|
-
// surface — `summrise stop` only Ends the running instance and the 5-min
|
|
1194
|
-
// watchdog revives it, so stop != opting out of autostart. This flips the
|
|
1195
|
-
// task ENABLED flag itself. /ENABLE|/DISABLE need no credentials, unlike
|
|
1196
|
-
// trigger edits which prompt for the /ru password interactively (and hang
|
|
1197
|
-
// the caller) — never add /RI /RU /RP /TR here. unit-tested.
|
|
1198
|
-
exports.BOOT_TASKS = ["SummriseAgent", "SummriseDesktop"];
|
|
1199
|
-
function autostartArgv(task, action) {
|
|
1200
|
-
return [
|
|
1201
|
-
"schtasks",
|
|
1202
|
-
"/Change",
|
|
1203
|
-
"/TN",
|
|
1204
|
-
task,
|
|
1205
|
-
action === "on" ? "/ENABLE" : "/DISABLE",
|
|
1206
|
-
];
|
|
1207
|
-
}
|
|
1208
|
-
// exported: the SummrisePlaywright probe launcher (playwright-probe.ps1).
|
|
1209
|
-
// Probe order matches the agent's preferred_cdp_endpoint(): 9333
|
|
1210
|
-
// (Electron DESKTOP embedded view — what the user watches) when up, else
|
|
1211
|
-
// private --headless. --output-dir pins MCP screenshots where the Evidence
|
|
1212
|
-
// drawer lists them (install\pwout). ASCII-only, plain -NoProfile -File
|
|
1213
|
-
// (the repo rule: -ExecutionPolicy Bypass / -EncodedCommand die silently
|
|
1214
|
-
// under WMI/session-0 launches). unit-tested in test/cli.test.mjs.
|
|
1215
|
-
function playwrightProbePs() {
|
|
1216
|
-
return [
|
|
1217
|
-
"param([string]$node, [string]$cli)",
|
|
1218
|
-
"$ErrorActionPreference = 'Continue'",
|
|
1219
|
-
"$pwout = Join-Path (Split-Path $node -Parent) '..\\pwout'",
|
|
1220
|
-
"if (!(Test-Path $pwout)) { New-Item -ItemType Directory -Path $pwout -Force | Out-Null }",
|
|
1221
|
-
"$ep = ''",
|
|
1222
|
-
"function Test-Port([int]$port) {",
|
|
1223
|
-
" try {",
|
|
1224
|
-
" $c = New-Object System.Net.Sockets.TcpClient",
|
|
1225
|
-
" $iar = $c.BeginConnect('127.0.0.1', $port, $null, $null)",
|
|
1226
|
-
" if ($iar.AsyncWaitHandle.WaitOne(1500)) { return $c.Connected }",
|
|
1227
|
-
" $c.Close()",
|
|
1228
|
-
" } catch { }",
|
|
1229
|
-
" return $false",
|
|
1230
|
-
"}",
|
|
1231
|
-
// Boot race: the task can fire before the desktop's CDP is up (Electron
|
|
1232
|
-
// starts at logon, later than the task). A single check then forks a
|
|
1233
|
-
// private headless chromium nobody sees (device-caught: detached 9229
|
|
1234
|
-
// serving about:blank while the user watched the embedded view). Wait
|
|
1235
|
-
// up to ~60s for 9333 before falling back to headless.
|
|
1236
|
-
"for ($i = 1; $i -le 12; $i++) { if (Test-Port 9333) { $ep = 'http://127.0.0.1:9333'; break }; Start-Sleep -Seconds 5 }",
|
|
1237
|
-
"if ($ep) {",
|
|
1238
|
-
" & $node $cli --port 9229 --host 127.0.0.1 --cdp-endpoint $ep --output-dir $pwout --ignore-https-errors --allowed-hosts '127.0.0.1:9229,localhost:9229'",
|
|
1239
|
-
"} else {",
|
|
1240
|
-
" & $node $cli --port 9229 --browser chromium --host 127.0.0.1 --headless --output-dir $pwout --ignore-https-errors --allowed-hosts '127.0.0.1:9229,localhost:9229'",
|
|
1241
|
-
"}",
|
|
1242
|
-
];
|
|
1243
|
-
}
|
|
1244
|
-
// exported: the update mutual-exclusion window (npm audit #10 seam), unit-tested.
|
|
1245
|
-
//
|
|
1246
|
-
// TEN MINUTES, AND IT MUST EQUAL THE AGENT'S `BUSY_STALE_SECS`. It did not: this
|
|
1247
|
-
// reclaimed at ten minutes while `agent/src/plugins/update/tools.rs` refused for
|
|
1248
|
-
// an hour, so at eleven minutes this side OVERWROTE a marker the agent still
|
|
1249
|
-
// honoured and a CLI update could start alongside a console-launched one —
|
|
1250
|
-
// interleaving `Copy-Item` on `*.new`, the half-written-exe hazard this marker
|
|
1251
|
-
// exists to prevent (npm audit #10). The agent now uses ten minutes too, and
|
|
1252
|
-
// `test/cli.test.mjs` pins the two numbers against each other, because no test
|
|
1253
|
-
// inside either language can see the other's.
|
|
1254
|
-
function busyIsFresh(mtimeMs, nowMs) {
|
|
1255
|
-
return nowMs - mtimeMs < 10 * 60 * 1000;
|
|
1256
|
-
}
|
|
1257
|
-
/**
|
|
1258
|
-
* The newest version the release CDN advertises, or `null` when it cannot be
|
|
1259
|
-
* read. Never throws and never guesses — the caller renders `null` as "could not
|
|
1260
|
-
* be checked", because a silent failure here is indistinguishable from "current".
|
|
1261
|
-
*
|
|
1262
|
-
* Uses `curl` (what `summrise rollback` already uses for its HEAD check) rather than
|
|
1263
|
-
* a Node HTTP client: it inherits the proxy and TLS store the rest of the CLI
|
|
1264
|
-
* relies on, and a 3 s cap keeps `status` from hanging on a bad network.
|
|
1265
|
-
*/
|
|
1266
|
-
function latestCdnVersion() {
|
|
1267
|
-
const base = (process.env.SUMMRISE_CDN || "https://agent.saisi.online").replace(/\/+$/, "");
|
|
1268
|
-
const r = (0, child_process_1.spawnSync)("curl", ["-s", "-m", "3", `${base}/api/version`], {
|
|
1269
|
-
encoding: "utf8",
|
|
1270
|
-
timeout: 5000,
|
|
1271
|
-
});
|
|
1272
|
-
if (r.status !== 0 || !r.stdout)
|
|
1273
|
-
return null;
|
|
1274
|
-
try {
|
|
1275
|
-
const j = JSON.parse(r.stdout);
|
|
1276
|
-
const v = j && typeof j.version === "string" ? j.version.trim() : "";
|
|
1277
|
-
return v || null;
|
|
1278
|
-
}
|
|
1279
|
-
catch {
|
|
1280
|
-
// A body that is not JSON is NOT a version — the same rule the gateway's
|
|
1281
|
-
// tool path had to learn.
|
|
1282
|
-
return null;
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
/**
|
|
1286
|
-
* The registry's own answer to "what is latest", asked in the smallest way npm
|
|
1287
|
-
* offers: `/-/package/<name>/dist-tags` is a few bytes, where the packument is a
|
|
1288
|
-
* document. Same 3 s cap and the same rule as the CDN read — a failure is
|
|
1289
|
-
* `null`, which the report renders as "could NOT be checked", never as
|
|
1290
|
-
* agreement.
|
|
1291
|
-
*/
|
|
1292
|
-
function latestNpmVersion() {
|
|
1293
|
-
const r = (0, child_process_1.spawnSync)("curl", [
|
|
1294
|
-
"-s",
|
|
1295
|
-
"-m",
|
|
1296
|
-
"3",
|
|
1297
|
-
"https://registry.npmjs.org/-/package/summrise-agent/dist-tags",
|
|
1298
|
-
], { encoding: "utf8", timeout: 5000 });
|
|
1299
|
-
if (r.status !== 0 || !r.stdout)
|
|
1300
|
-
return null;
|
|
1301
|
-
try {
|
|
1302
|
-
const j = JSON.parse(r.stdout);
|
|
1303
|
-
const v = j && typeof j.latest === "string" ? j.latest.trim() : "";
|
|
1304
|
-
// A dist-tag can point at a prerelease; the device's update path only
|
|
1305
|
-
// understands x.y.z, and a tag it cannot parse is not a version.
|
|
1306
|
-
return /^\d+\.\d+\.\d+$/.test(v) ? v : null;
|
|
1307
|
-
}
|
|
1308
|
-
catch {
|
|
1309
|
-
return null;
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
/** The newer of two release versions; either may be unknown. */
|
|
1313
|
-
function newestOf(a, b) {
|
|
1314
|
-
if (!a)
|
|
1315
|
-
return b;
|
|
1316
|
-
if (!b)
|
|
1317
|
-
return a;
|
|
1318
|
-
const ta = versionTriple(a);
|
|
1319
|
-
const tb = versionTriple(b);
|
|
1320
|
-
if (!ta)
|
|
1321
|
-
return b;
|
|
1322
|
-
if (!tb)
|
|
1323
|
-
return a;
|
|
1324
|
-
for (let i = 0; i < 3; i++) {
|
|
1325
|
-
if (ta[i] !== tb[i])
|
|
1326
|
-
return ta[i] > tb[i] ? a : b;
|
|
1327
|
-
}
|
|
1328
|
-
return a;
|
|
1329
|
-
}
|
|
1330
|
-
/**
|
|
1331
|
-
* What "latest" means now that there are TWO channels: the newer of the CDN's
|
|
1332
|
-
* `version.json` and the registry's dist-tag. `null` only when NEITHER
|
|
1333
|
-
* answered — one silent channel must not be reported as agreement, which is the
|
|
1334
|
-
* property the CDN-only version already had.
|
|
1335
|
-
*/
|
|
1336
|
-
function latestReleaseVersion() {
|
|
1337
|
-
return newestOf(latestCdnVersion(), latestNpmVersion());
|
|
1338
|
-
}
|
|
1339
|
-
/** The release host this CLI already trusts for version checks. */
|
|
1340
|
-
function cdnBase() {
|
|
1341
|
-
return (process.env.SUMMRISE_CDN || "https://agent.saisi.online").replace(/\/+$/, "");
|
|
1342
|
-
}
|
|
1343
|
-
/** Where a boxed component lives on that host. Pure — the tests pin it. */
|
|
1344
|
-
function componentUrl(name) {
|
|
1345
|
-
return `${cdnBase()}/summrise-agent/${name}`;
|
|
1346
|
-
}
|
|
1347
|
-
/**
|
|
1348
|
-
* The address to FETCH a boxed component from: the release manifest's own `url` for it, or
|
|
1349
|
-
* `componentUrl`'s derived route when the manifest carries none.
|
|
1350
|
-
*
|
|
1351
|
-
* WHY THE MANIFEST IS THE AUTHOR. Every release since the pins existed has published a `url`
|
|
1352
|
-
* beside each component's `sha256` in version.json, and this CLI read the digest while the
|
|
1353
|
-
* address was retyped here — one fact, two authors. They agree today, which is why reading the
|
|
1354
|
-
* manifest is a DRIFT GUARD rather than a fix: index/src/index.js rebuilds the published url
|
|
1355
|
-
* against the origin of the request that asked for `/api/version`, so the address it hands back
|
|
1356
|
-
* is the one this function would have derived — under $SUMMRISE_CDN too, where both move
|
|
1357
|
-
* together. A release whose url and route disagreed (a renamed bundle, the round-115 shape)
|
|
1358
|
-
* now fetches what it published; a silent manifest (an older release, or no network) keeps the
|
|
1359
|
-
* derived route, which is what every release before this one did.
|
|
1360
|
-
*
|
|
1361
|
-
* Pure — the tests pin it. The digest check beside it is unchanged.
|
|
1362
|
-
*/
|
|
1363
|
-
function componentFetchUrl(name, pins) {
|
|
1364
|
-
const key = componentKey(name);
|
|
1365
|
-
const u = key ? (pins[key] || {}).url : "";
|
|
1366
|
-
return typeof u === "string" && /^https?:\/\/\S+$/.test(u.trim())
|
|
1367
|
-
? u.trim()
|
|
1368
|
-
: componentUrl(name);
|
|
1369
|
-
}
|
|
1370
|
-
/** The release manifest's key for a component's FILE name (they are not the same string). */
|
|
1371
|
-
function componentKey(fileName) {
|
|
1372
|
-
if (fileName.startsWith("summrise-playwright"))
|
|
1373
|
-
return "playwright";
|
|
1374
|
-
if (fileName.startsWith("electron-"))
|
|
1375
|
-
return "electron";
|
|
1376
|
-
if (fileName.startsWith("cloudflared"))
|
|
1377
|
-
return "cloudflared";
|
|
1378
|
-
return null; // a file this release does not pin (e.g. fix-tunnel.ps1)
|
|
1379
|
-
}
|
|
1380
|
-
/** The manifest's component pins, or {} when it carries none (an older release). */
|
|
1381
|
-
function componentPins() {
|
|
1382
|
-
const r = (0, child_process_1.spawnSync)("curl", ["-s", "-m", "5", `${cdnBase()}/api/version`], {
|
|
1383
|
-
encoding: "utf8",
|
|
1384
|
-
timeout: 8000,
|
|
1385
|
-
});
|
|
1386
|
-
if (r.status !== 0 || !r.stdout)
|
|
1387
|
-
return {};
|
|
1388
|
-
try {
|
|
1389
|
-
const j = JSON.parse(r.stdout);
|
|
1390
|
-
const c = j && typeof j.components === "object" ? j.components : null;
|
|
1391
|
-
return c || {};
|
|
1392
|
-
}
|
|
1393
|
-
catch {
|
|
1394
|
-
return {};
|
|
1395
|
-
}
|
|
1396
|
-
}
|
|
1397
|
-
/** sha256 of a file. Read whole: the electron runtime is 115 MB and node's heap is fine with it. */
|
|
1398
|
-
function sha256File(p) {
|
|
1399
|
-
return crypto.createHash("sha256").update(fs.readFileSync(p)).digest("hex");
|
|
1400
|
-
}
|
|
1401
|
-
/**
|
|
1402
|
-
* A boxed component's local path: the copy inside the package if it is there,
|
|
1403
|
-
* otherwise the release host's route for it, downloaded to a temp file.
|
|
1404
|
-
*
|
|
1405
|
-
* WHY THIS EXISTS (2026-09-23). The npm package carries NONE of the big
|
|
1406
|
-
* binaries on purpose (cloudflared 54 MB, playwright 31 MB, electron 234 MB), so
|
|
1407
|
-
* "not in the package" used to mean "you do not get it, ever" — setup printed a
|
|
1408
|
-
* line and moved on. The same shape for cloudflared left a freshly migrated
|
|
1409
|
-
* device with no tunnel, and a device with no tunnel is INVISIBLE TO THE
|
|
1410
|
-
* CONSOLE while looking perfectly healthy from inside: an hour of that is what
|
|
1411
|
-
* this function is for. The release host serves all three, and for cloudflared
|
|
1412
|
-
* it proxies a PINNED upstream asset, which the agent's own sha256 pin then
|
|
1413
|
-
* checks again on the path it uses.
|
|
1414
|
-
*
|
|
1415
|
-
* `null` when neither source works: every component here is optional to the
|
|
1416
|
-
* AGENT, and a failed fetch must not fail the install.
|
|
1417
|
-
*
|
|
1418
|
-
* THE FIRST ARM IS A SEAM, NOT A LIVE PATH — measured, because a reader deserves to know which: no
|
|
1419
|
-
* component is in `package.json`'s `files[]` or in `required-in-tgz.txt`, and `tar tzf` on the published
|
|
1420
|
-
* tarball confirms none of the three is there. So today the release host is the ONLY source, and the
|
|
1421
|
-
* `existsSync` above exists so that a future release which DOES box one gets it from the package without
|
|
1422
|
-
* touching this function. The test beside it pins the fact, so boxing one is a deliberate act that also
|
|
1423
|
-
* updates this sentence.
|
|
1424
|
-
*/
|
|
1425
|
-
function resolveComponent(name, pkgPath) {
|
|
1426
|
-
if (fs.existsSync(pkgPath))
|
|
1427
|
-
return pkgPath;
|
|
1428
|
-
const dest = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "summrise-comp-")), name);
|
|
1429
|
-
// -f: an HTTP error is a FAILURE, not a 404 page written to disk (the
|
|
1430
|
-
// HTML-polluted download that round-54 exists to remember).
|
|
1431
|
-
//
|
|
1432
|
-
// THE MANIFEST IS READ ONCE, BEFORE THE FETCH, because it is the author of the DIGEST (below)
|
|
1433
|
-
// and now of the ADDRESS too (componentFetchUrl). Same read, same object, same order of
|
|
1434
|
-
// effects: an unreachable manifest yields `{}`, the fetch falls back to the derived route, and
|
|
1435
|
-
// the digest check then says "fetched WITHOUT a manifest pin — not verified", exactly as it
|
|
1436
|
-
// did when the address was derived unconditionally.
|
|
1437
|
-
const pins = componentPins();
|
|
1438
|
-
const r = (0, child_process_1.spawnSync)("curl", ["-fsSL", "-m", "300", "-o", dest, componentFetchUrl(name, pins)], { encoding: "utf8", timeout: 320000 });
|
|
1439
|
-
if (r.status !== 0 || !fs.existsSync(dest) || fs.statSync(dest).size === 0) {
|
|
1440
|
-
return null;
|
|
1441
|
-
}
|
|
1442
|
-
// VERIFY WHAT WAS FETCHED (grilling Q4). Until this existed, a worker serving
|
|
1443
|
-
// different bytes would have been staged without complaint — the components are
|
|
1444
|
-
// executed or loaded, so "the host said so" is not an anchor. The manifest that
|
|
1445
|
-
// /api/version serves carries a sha256 per component (index/components.json,
|
|
1446
|
-
// copied in at publish time); a pin that does not match REFUSES the component,
|
|
1447
|
-
// because the failure it prevents is a device running bytes nobody published.
|
|
1448
|
-
// No pin at all (an older release) is a warning, not a refusal: it must not make
|
|
1449
|
-
// an install impossible.
|
|
1450
|
-
//
|
|
1451
|
-
// `pins` is the read from above — ONE manifest read per component fetch, exactly as before; the
|
|
1452
|
-
// check itself is unchanged.
|
|
1453
|
-
const key = componentKey(name);
|
|
1454
|
-
const pin = key ? (pins[key] || {}).sha256 || "" : "";
|
|
1455
|
-
if (pin) {
|
|
1456
|
-
const got = sha256File(dest);
|
|
1457
|
-
if (got !== pin) {
|
|
1458
|
-
console.error(`setup: REFUSING ${name} — sha256 ${got.slice(0, 12)}… does not match the release manifest's ${pin.slice(0, 12)}… (the host served different bytes than it published)`);
|
|
1459
|
-
return null;
|
|
1460
|
-
}
|
|
1461
|
-
console.log(`setup: ${name} verified against the release manifest (${pin.slice(0, 12)}…)`);
|
|
1462
|
-
}
|
|
1463
|
-
else if (key) {
|
|
1464
|
-
console.log(`setup: ${name} fetched WITHOUT a manifest pin — not verified`);
|
|
1465
|
-
}
|
|
1466
|
-
console.log(`setup: ${name} fetched from the release host (not in the package)`);
|
|
1467
|
-
return dest;
|
|
1468
|
-
}
|
|
1469
|
-
/**
|
|
1470
|
-
* Ensure the desktop shell's electron RUNTIME. The shell's sources are ~90 KB
|
|
1471
|
-
* and ship in the package; the runtime is 234 MB and deliberately does not.
|
|
1472
|
-
*
|
|
1473
|
-
* WHY IT IS FETCHED AND NOT `npx electron`: the release host serves a PINNED
|
|
1474
|
-
* upstream proxy of the official build (v33.4.11 when this was written), so a
|
|
1475
|
-
* device behind the GFW needs no GitHub access and no ELECTRON_MIRROR — the two
|
|
1476
|
-
* things that made the hand-install on 2026-09-23 need a mirror variable at all.
|
|
1477
|
-
* `npx electron` would also mean rewriting the launcher, which calls
|
|
1478
|
-
* `<shell>\node_modules\electron\dist\electron.exe` directly.
|
|
1479
|
-
*
|
|
1480
|
-
* The zip's ROOT is the dist contents (`LICENSES.chromium.html` is its first
|
|
1481
|
-
* entry — measured through the route, not assumed), so it expands into `dist`.
|
|
1482
|
-
* Idempotent, and non-fatal: an install without a window still installs an agent.
|
|
1483
|
-
*/
|
|
1484
|
-
function ensureElectron() {
|
|
1485
|
-
const elDir = path.join(DESK_DIR, "node_modules", "electron");
|
|
1486
|
-
const dist = path.join(elDir, "dist");
|
|
1487
|
-
if (fs.existsSync(path.join(dist, "electron.exe")))
|
|
1488
|
-
return dist;
|
|
1489
|
-
const zip = resolveComponent("electron-win32-x64.zip", path.join(__dirname, "..", "electron-win32-x64.zip"));
|
|
1490
|
-
if (!zip)
|
|
1491
|
-
return null;
|
|
1492
|
-
fs.mkdirSync(dist, { recursive: true });
|
|
1493
|
-
// argv via `ps()`, not a cmd string: BOTH values are paths, and cmd expands `%NAME%`
|
|
1494
|
-
// even inside the double quotes the old form carried (see `sh()`).
|
|
1495
|
-
ps(`Expand-Archive -Force -Path '${(0, exports.psq)(zip)}' -DestinationPath '${(0, exports.psq)(dist)}'`);
|
|
1496
|
-
if (!fs.existsSync(path.join(dist, "electron.exe")))
|
|
1497
|
-
return null;
|
|
1498
|
-
try {
|
|
1499
|
-
// The electron package's own lookup file; the launcher does not need it, but
|
|
1500
|
-
// anything that resolves the package the npm way will.
|
|
1501
|
-
fs.writeFileSync(path.join(elDir, "path.txt"), "electron.exe");
|
|
1502
|
-
}
|
|
1503
|
-
catch {
|
|
1504
|
-
/* best-effort */
|
|
1505
|
-
}
|
|
1506
|
-
return dist;
|
|
1507
|
-
}
|
|
1508
|
-
function statusReport(f) {
|
|
1509
|
-
const out = [];
|
|
1510
|
-
out.push(f.agentRunning === null
|
|
1511
|
-
? "status: UNKNOWN -- the process list could not be read (tasklist failed); this is not a verdict"
|
|
1512
|
-
: f.agentRunning
|
|
1513
|
-
? "status: RUNNING"
|
|
1514
|
-
: "status: STOPPED");
|
|
1515
|
-
out.push("install dir: " + f.installDir);
|
|
1516
|
-
out.push("panel: " +
|
|
1517
|
-
(f.exeExists ? `http://127.0.0.1:${f.port}/panel/` : "(not installed)"));
|
|
1518
|
-
// A device without a release marker is not "on some version" — it is a device
|
|
1519
|
-
// whose version is UNKNOWN (a fresh box, or an install predating the marker).
|
|
1520
|
-
// Printing this CLI's version here would be a fabricated fact.
|
|
1521
|
-
out.push("release: " +
|
|
1522
|
-
(f.releaseVersion ? f.releaseVersion : "unknown (no release marker)"));
|
|
1523
|
-
out.push("this CLI: " + f.packageVersion);
|
|
1524
|
-
if (f.updateMarkerMs === null) {
|
|
1525
|
-
out.push(f.updateMarkerUnreadable
|
|
1526
|
-
? "update: state UNKNOWN -- the busy marker exists but could not be read (permissions or a lock); do not assume no update is running"
|
|
1527
|
-
: "update: none in flight");
|
|
1528
|
-
}
|
|
1529
|
-
else if (busyIsFresh(f.updateMarkerMs, f.nowMs)) {
|
|
1530
|
-
const secs = Math.max(0, Math.round((f.nowMs - f.updateMarkerMs) / 1000));
|
|
1531
|
-
out.push(`update: IN FLIGHT (marker ${secs}s old -- a swap is running now; the connection drops for ~10s)`);
|
|
1532
|
-
}
|
|
1533
|
-
else {
|
|
1534
|
-
const mins = Math.round((f.nowMs - f.updateMarkerMs) / 60_000);
|
|
1535
|
-
out.push(`update: a previous update STARTED AND DID NOT FINISH (marker ${mins} min old). ` +
|
|
1536
|
-
`Check the log tail, then re-run 'summrise update' -- a stale marker is safe to overwrite.`);
|
|
1537
|
-
}
|
|
1538
|
-
// The drift line: what a human actually wants from `status` after an update.
|
|
1539
|
-
// Only claimed when the running version is KNOWN — otherwise the comparison
|
|
1540
|
-
// would be against a guess.
|
|
1541
|
-
if (f.releaseVersion && f.releaseVersion !== f.packageVersion) {
|
|
1542
|
-
out.push(`update: device runs ${f.releaseVersion}, this CLI is ${f.packageVersion} -- ` +
|
|
1543
|
-
`run 'summrise update' to swap, then 'summrise status' again to confirm.`);
|
|
1544
|
-
}
|
|
1545
|
-
// THE DELIVERY GAP, WHICH NOTHING ELSE IN THIS REPO CHECKS.
|
|
1546
|
-
//
|
|
1547
|
-
// `release` answers "what is this device running"; it did NOT answer "is that
|
|
1548
|
-
// current", and the two questions are answered by different machines. Every
|
|
1549
|
-
// round of this project's log records a device found MANY RELEASES BEHIND the
|
|
1550
|
-
// CDN — five, six, once three in a single round — and each time the only thing
|
|
1551
|
-
// that noticed was a human looking. This closes it where a human already
|
|
1552
|
-
// looks: the one command run after every update.
|
|
1553
|
-
//
|
|
1554
|
-
// `null` IS NOT "UP TO DATE". If the CDN could not be read the line says so
|
|
1555
|
-
// instead of staying silent, because silence here reads exactly like
|
|
1556
|
-
// agreement — the failure mode this whole log is about.
|
|
1557
|
-
// `== null` COVERS `undefined` TOO, AND THAT IS THE POINT. The first version of
|
|
1558
|
-
// this tested `=== null`, and a caller that simply OMITS the field — an older
|
|
1559
|
-
// edition, a test fixture — fell through to the drift branch with `undefined`
|
|
1560
|
-
// and crashed reading `.split` of nothing. Missing and null are both "we do not
|
|
1561
|
-
// know what the CDN has"; only a STRING is a comparison. (The panel learned the
|
|
1562
|
-
// same distinction the hard way in round 36: `undefined` is not `null`.)
|
|
1563
|
-
if (f.latestVersion == null) {
|
|
1564
|
-
out.push("latest: could NOT be checked (the release CDN did not answer) -- this says nothing about whether the device is current");
|
|
1565
|
-
}
|
|
1566
|
-
else if (f.releaseVersion && f.releaseVersion !== f.latestVersion) {
|
|
1567
|
-
out.push(`latest: ${f.latestVersion} is on the CDN -- THIS DEVICE IS BEHIND by ${behindBy(f.releaseVersion, f.latestVersion)}; run 'summrise update'`);
|
|
1568
|
-
}
|
|
1569
|
-
else if (f.releaseVersion) {
|
|
1570
|
-
out.push(`latest: ${f.latestVersion} (this device is current)`);
|
|
1571
|
-
}
|
|
1572
|
-
return out;
|
|
1573
|
-
}
|
|
1574
|
-
/**
|
|
1575
|
-
* How far behind, in patch releases, WITHIN THE SAME MINOR. Returns a phrase,
|
|
1576
|
-
* never a fabricated number: `1.2.9` → `1.2.12` is "3 releases", but a MINOR or
|
|
1577
|
-
* MAJOR difference is not a count of anything a reader can act on, so it is
|
|
1578
|
-
* stated as such. The last-5-per-minor CDN prune means a cross-minor jump is a
|
|
1579
|
-
* different operation anyway (`summrise rollback` refuses it for the same reason).
|
|
1580
|
-
*/
|
|
1581
|
-
/** ONE PARSE, TWO PRESENTATIONS (round 202). The comparison between two x.y.z strings was written out once, for the
|
|
1582
|
-
* sentence `status` prints. The update guard needed the same fact as a BOOLEAN, and its first version tested the
|
|
1583
|
-
* sentence's truthiness — where "0 releases" and "-1 releases" are both TRUTHY, so it would have refused every update,
|
|
1584
|
-
* including the correct one. Caught by asking the artefact before shipping it. */
|
|
1585
|
-
function versionTriple(v) {
|
|
1586
|
-
const t = String(v || "")
|
|
1587
|
-
.split(".")
|
|
1588
|
-
.map(Number);
|
|
1589
|
-
return t.length === 3 && t.every((n) => Number.isFinite(n)) ? t : null;
|
|
1590
|
-
}
|
|
1591
|
-
/** WOULD THIS UPDATE MOVE THE DEVICE AT ALL? The parity fact, and it needs NO NETWORK: the CLI stamps
|
|
1592
|
-
* `<install>/.summrise-release` with ITS OWN version, and the device's `agent_update` reads that file as
|
|
1593
|
-
* the local version — so when the device is already on the version this CLI carries, the stamp equals
|
|
1594
|
-
* what is there, the swap installs the same build, and nothing moves. That is round 201's measured
|
|
1595
|
-
* defect ("update requested 1.2.438 -> 1.2.438", "copy ok=True", release unchanged), which is why the
|
|
1596
|
-
* guard below exists at all. THE GUARD THAT EXISTS CANNOT SEE IT: it compares the CLI against the
|
|
1597
|
-
* RELEASE CHANNEL, and when the CDN is unreadable `latest` is empty and the whole check is skipped, so a
|
|
1598
|
-
* network blip re-opens the defect. This one compares two facts already on the machine. */
|
|
1599
|
-
function updateWouldNotMove(fromVersion, selfVersion) {
|
|
1600
|
-
return Boolean(fromVersion) && fromVersion === selfVersion;
|
|
1601
|
-
}
|
|
1602
|
-
/** Is `latest` ahead of `device` on the SAME release line? */
|
|
1603
|
-
function isBehind(device, latest) {
|
|
1604
|
-
const a = versionTriple(device);
|
|
1605
|
-
const b = versionTriple(latest);
|
|
1606
|
-
if (!a || !b)
|
|
1607
|
-
return false;
|
|
1608
|
-
if (a[0] !== b[0] || a[1] !== b[1])
|
|
1609
|
-
return false;
|
|
1610
|
-
return b[2] > a[2];
|
|
1611
|
-
}
|
|
1612
|
-
function behindBy(device, latest) {
|
|
1613
|
-
const a = versionTriple(device);
|
|
1614
|
-
const b = versionTriple(latest);
|
|
1615
|
-
if (!a || !b) {
|
|
1616
|
-
return "an unknown number of releases";
|
|
1617
|
-
}
|
|
1618
|
-
if (a[0] !== b[0] || a[1] !== b[1])
|
|
1619
|
-
return "a release line, not a patch count";
|
|
1620
|
-
const n = b[2] - a[2];
|
|
1621
|
-
return n === 1 ? "1 release" : `${n} releases`;
|
|
1622
|
-
}
|
|
1623
|
-
/**
|
|
1624
|
-
* The CLI's pre-handoff receipt, appended to the swap's own log.
|
|
1625
|
-
*
|
|
1626
|
-
* Takes the resolved DATA dir and rebuilds the same `<data>\logs\summrise-update.log`
|
|
1627
|
-
* path the swap script appends to — NOT a `..` relative guess, because
|
|
1628
|
-
* `DATA_DIR` can be a registry-remapped location that is not `DIR`, and a
|
|
1629
|
-
* receipt written to a different file than the swap writes is worse than none:
|
|
1630
|
-
* it would look like the swap never started.
|
|
1631
|
-
*
|
|
1632
|
-
* Deliberately NOT the swap's `update start` wording: the value of the receipt
|
|
1633
|
-
* is that its presence-without-`update start` proves the CLI ran and the swap
|
|
1634
|
-
* did not, so the two markers must stay distinguishable in the file.
|
|
1635
|
-
*/
|
|
1636
|
-
function updateReceiptPs(dataDirQ, fromVersion, toVersion) {
|
|
1637
|
-
const log = `Out-File '${dataDirQ}\\logs\\summrise-update.log' -Append`;
|
|
1638
|
-
const line = `"[$(Get-Date -Format o)] update requested ${fromVersion} -> ${toVersion} ` +
|
|
1639
|
-
`(CLI reached the device; the swap has not started yet)"`;
|
|
1640
|
-
return [`${line} | ${log}`];
|
|
1641
|
-
}
|
|
1642
|
-
/**
|
|
1643
|
-
* The update mutual-exclusion marker — ONE owner for the path.
|
|
1644
|
-
*
|
|
1645
|
-
* Three readers now depend on it agreeing: the mutual-exclusion check in
|
|
1646
|
-
* `setup()`, the guard in `update()`, and `statusReport`'s "is a swap pending"
|
|
1647
|
-
* line. If they ever computed the path differently, `status` would confidently
|
|
1648
|
-
* report "none in flight" while an update was refusing to start because a
|
|
1649
|
-
* marker it could not see was in the way.
|
|
1650
|
-
*
|
|
1651
|
-
* A SURVIVING STALE MARKER means the update started and never finished: the
|
|
1652
|
-
* swap script clears it on its own known-failure paths (task repoint, migration
|
|
1653
|
-
* gate) and after a successful restart, so one still sitting there is an update
|
|
1654
|
-
* that died before cleanup. `statusReport` deliberately reuses `busyIsFresh` —
|
|
1655
|
-
* the SAME predicate `update()` refuses on — so "stale" cannot mean one thing
|
|
1656
|
-
* to the guard and another to the report.
|
|
1657
|
-
*/
|
|
1658
|
-
function updateBusyPath() {
|
|
1659
|
-
return path.join(process.env.ProgramData || "C:\\ProgramData", "SummriseAgent", "update-busy");
|
|
1660
|
-
}
|
|
1661
|
-
/**
|
|
1662
|
-
* The SAME marker as the PowerShell text the swap script embeds, DERIVED from `updateBusyPath()` rather
|
|
1663
|
-
* than written out by hand — which is what this file did, three times, inside the generated script. The
|
|
1664
|
-
* agent fixed exactly this on its own side and says why (tools.rs, above BUSY_MARKER_REL): "It used to be
|
|
1665
|
-
* spelled out TWICE: the Rust acquirer built <ProgramData>\SummriseAgent\update-busy from PathBuf joins
|
|
1666
|
-
* while the generated PowerShell swap script carried the same location as two hand-written string
|
|
1667
|
-
* literals. A drift between the two is invisible until an update actually runs — and then the swap
|
|
1668
|
-
* releases a file the agent never created, the marker survives, and every later update is refused for up
|
|
1669
|
-
* to an hour." The CLI's own doc above claims ONE owner for this path; this is what makes that true.
|
|
1670
|
-
*/
|
|
1671
|
-
function busyMarkerPs() {
|
|
1672
|
-
const root = process.env.ProgramData || "C:\\ProgramData";
|
|
1673
|
-
const rel = path.relative(root, updateBusyPath()).split(path.sep).join("\\");
|
|
1674
|
-
return `(Join-Path $env:ProgramData '${rel}')`;
|
|
1675
|
-
}
|
|
1676
|
-
/**
|
|
1677
|
-
* Poll `etc\.summrise-release` until it shows `want`, or the budget expires.
|
|
1678
|
-
*
|
|
1679
|
-
* Bounded on purpose: the swap kills the agent and restarts a scheduled task, so
|
|
1680
|
-
* a delay is NORMAL — but a marker that never arrives is a failed swap and must
|
|
1681
|
-
* be reported as one. `read`/`sleep`/`now` are injected so the behaviour is
|
|
1682
|
-
* testable without real timers or a device.
|
|
1683
|
-
*/
|
|
1684
|
-
async function awaitReleaseMarker(o) {
|
|
1685
|
-
const start = o.now();
|
|
1686
|
-
const deadline = start + o.timeoutMs;
|
|
1687
|
-
let saw = null;
|
|
1688
|
-
for (;;) {
|
|
1689
|
-
try {
|
|
1690
|
-
const v = String(o.read()).trim();
|
|
1691
|
-
saw = v || null;
|
|
1692
|
-
if (saw === o.want)
|
|
1693
|
-
return { ok: true, saw, waitedMs: o.now() - start };
|
|
1694
|
-
}
|
|
1695
|
-
catch {
|
|
1696
|
-
saw = null; // absent / unreadable is NOT success and NOT a crash
|
|
1697
|
-
}
|
|
1698
|
-
if (o.now() >= deadline)
|
|
1699
|
-
return { ok: false, saw, waitedMs: o.now() - start };
|
|
1700
|
-
await o.sleep(o.intervalMs);
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
/**
|
|
1704
|
-
* What to do about a rollback whose swap could not be proven.
|
|
1705
|
-
*
|
|
1706
|
-
* Separated from the I/O so the DECISION is assertable: the pin is the device's
|
|
1707
|
-
* protection against being auto-upgraded back, and the marker is what every UI
|
|
1708
|
-
* believes — writing either on an unproven swap is how a device ends up
|
|
1709
|
-
* misreporting its own version and refusing the update that would fix it.
|
|
1710
|
-
*/
|
|
1711
|
-
function releaseMarkerVerdict(c) {
|
|
1712
|
-
// WHOSE FAILURE IS THIS? The message is printed by TWO callers — `rollback`, which staged a release in
|
|
1713
|
-
// order to PIN it, and `update`, which staged one to INSTALL it — and it said "rollback:" with
|
|
1714
|
-
// "re-run `summrise rollback <want>`" for both. On the update path that advice is worse than useless:
|
|
1715
|
-
// it names the version that just failed to install, so following it asks the device to pin a release
|
|
1716
|
-
// it is not running. The verb decides the sentence now.
|
|
1717
|
-
const verb = c.verb ?? "rollback";
|
|
1718
|
-
if (c.ok) {
|
|
1719
|
-
return {
|
|
1720
|
-
writePin: true,
|
|
1721
|
-
exitCode: 0,
|
|
1722
|
-
message: `${verb}: pinned to ${c.want} -- auto-upgrade refused until 'summrise rollback --clear' or a forced agent_update`,
|
|
1723
|
-
};
|
|
1724
|
-
}
|
|
1725
|
-
return {
|
|
1726
|
-
writePin: false,
|
|
1727
|
-
exitCode: 1,
|
|
1728
|
-
// WHAT WAS OBSERVED, NOT WHAT IT MEANS. `ok: false` is a TIMEOUT: either the swap
|
|
1729
|
-
// failed, or it is still running (it kills and restarts the agent), or the marker
|
|
1730
|
-
// could not be read at all — and `saw === null` is the weakest of the three. The old
|
|
1731
|
-
// wording asserted "the swap did NOT take" for all of them.
|
|
1732
|
-
message: `${verb}: no release marker showing ${c.want} within ${Number.isFinite(c.waitedMs)
|
|
1733
|
-
? Math.round(c.waitedMs / 1000) + "s"
|
|
1734
|
-
: "the read-back window"} ` +
|
|
1735
|
-
`(last read: ${c.saw ?? "empty or unreadable"}). ` +
|
|
1736
|
-
`NOT pinned (a pin would claim a version this device may not be running) and no release ` +
|
|
1737
|
-
`marker written. ` +
|
|
1738
|
-
(verb === "update"
|
|
1739
|
-
? `Check the update log and \`summrise status\`, then re-run \`summrise update\`. If it fails ` +
|
|
1740
|
-
`the same way, \`summrise rollback ${c.from ?? "<the version it was on>"}\` pins the release ` +
|
|
1741
|
-
`this device is ACTUALLY running — which is not the one that failed to install.`
|
|
1742
|
-
: `Check the update log and \`summrise status\`, then re-run 'summrise rollback ${c.want}'.`),
|
|
1743
|
-
};
|
|
1744
|
-
}
|
|
1745
|
-
function boxedVersions(installDir, pkgDir) {
|
|
1746
|
-
const pkgVer = (p) => {
|
|
1747
|
-
try {
|
|
1748
|
-
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
1749
|
-
return typeof j?.version === "string" && j.version
|
|
1750
|
-
? j.version
|
|
1751
|
-
: "unknown";
|
|
1752
|
-
}
|
|
1753
|
-
catch {
|
|
1754
|
-
return "unknown";
|
|
1755
|
-
}
|
|
1756
|
-
};
|
|
1757
|
-
const shaOf = (p) => {
|
|
1758
|
-
try {
|
|
1759
|
-
const st = fs.statSync(p);
|
|
1760
|
-
if (!st.isFile() || st.size > 300 * 1024 * 1024)
|
|
1761
|
-
return "unknown";
|
|
1762
|
-
return crypto
|
|
1763
|
-
.createHash("sha256")
|
|
1764
|
-
.update(fs.readFileSync(p))
|
|
1765
|
-
.digest("hex");
|
|
1766
|
-
}
|
|
1767
|
-
catch {
|
|
1768
|
-
return "unknown";
|
|
1769
|
-
}
|
|
1770
|
-
};
|
|
1771
|
-
// Layout v2: callers (setup/update) always run after staging/migration, so
|
|
1772
|
-
// the components\ homes exist — no legacy fallback (single semantic).
|
|
1773
|
-
const cfBin = fs.existsSync(path.join(installDir, "components", "cloudflared.exe"))
|
|
1774
|
-
? path.join(installDir, "components", "cloudflared.exe")
|
|
1775
|
-
: path.join(pkgDir, "cloudflared.exe");
|
|
1776
|
-
let cfVer = "unknown";
|
|
1777
|
-
try {
|
|
1778
|
-
if (fs.existsSync(cfBin)) {
|
|
1779
|
-
const r = (0, child_process_1.spawnSync)(cfBin, ["--version"], {
|
|
1780
|
-
encoding: "utf8",
|
|
1781
|
-
timeout: 15000,
|
|
1782
|
-
});
|
|
1783
|
-
const line = ((r.stdout || "") + (r.stderr || ""))
|
|
1784
|
-
.split(/\r?\n/)[0]
|
|
1785
|
-
.trim();
|
|
1786
|
-
if (line)
|
|
1787
|
-
cfVer = line.slice(0, 120);
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
catch {
|
|
1791
|
-
/* best-effort */
|
|
1792
|
-
}
|
|
1793
|
-
const pwRoot = path.join(installDir, "components", "playwright");
|
|
1794
|
-
return {
|
|
1795
|
-
updated: new Date().toISOString(),
|
|
1796
|
-
playwright_mcp: {
|
|
1797
|
-
version: pkgVer(path.join(pwRoot, "node_modules", "@playwright", "mcp", "package.json")),
|
|
1798
|
-
sha256: shaOf(path.join(pkgDir, "summrise-playwright.zip")),
|
|
1799
|
-
},
|
|
1800
|
-
playwright_core: {
|
|
1801
|
-
version: pkgVer(path.join(pwRoot, "node_modules", "playwright-core", "package.json")),
|
|
1802
|
-
sha256: "unknown",
|
|
1803
|
-
},
|
|
1804
|
-
cloudflared: {
|
|
1805
|
-
version: cfVer,
|
|
1806
|
-
sha256: fs.existsSync(cfBin) ? shaOf(cfBin) : "unknown",
|
|
1807
|
-
},
|
|
1808
|
-
};
|
|
1809
|
-
}
|
|
1810
|
-
// exported: best-effort writer for the P2-4 manifest (never throws).
|
|
1811
|
-
function writeBoxedVersions(installDir, pkgDir) {
|
|
1812
|
-
try {
|
|
1813
|
-
fs.mkdirSync(path.join(installDir, "etc"), { recursive: true });
|
|
1814
|
-
fs.writeFileSync(path.join(installDir, "etc", "boxed-versions.json"), JSON.stringify(boxedVersions(installDir, pkgDir), null, 2));
|
|
1815
|
-
}
|
|
1816
|
-
catch (e) {
|
|
1817
|
-
console.log("boxed-versions: manifest write skipped (" + (e?.message || e) + ")");
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
// round-298 parity: record this package's release version next to the install
|
|
1821
|
-
// dir as `.summrise-release` — the file agent_update reads as the LOCAL version
|
|
1822
|
-
// (fallback: Cargo 1.0.x, which never changes, so remote always looks newer
|
|
1823
|
-
// and every agent_update call re-downloads + swaps). `summrise update` writes it
|
|
1824
|
-
// from the swap script after a provable copy; `summrise setup` (fresh install)
|
|
1825
|
-
// copies THIS package's exe, so the provable-success point is right after the
|
|
1826
|
-
// boot task registers — the caller invokes this only once setup succeeded.
|
|
1827
|
-
// Best-effort, never fail-closed (a marker failure must not block install).
|
|
1828
|
-
function writeReleaseMarker(installDir) {
|
|
1829
|
-
try {
|
|
1830
|
-
const v = String(require("../package.json").version || "");
|
|
1831
|
-
if (!v)
|
|
1832
|
-
return;
|
|
1833
|
-
// No mkdir: callers (setup/update) always run after staging/migration,
|
|
1834
|
-
// so etc\ exists — a missing dir stays a silent best-effort skip.
|
|
1835
|
-
fs.writeFileSync(path.join(installDir, "etc", ".summrise-release"), v, "utf8");
|
|
1836
|
-
}
|
|
1837
|
-
catch {
|
|
1838
|
-
/* best-effort */
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
/**
|
|
1842
|
-
* Stage the Electron desktop shell sources (main/preload/url-policy +
|
|
1843
|
-
* icons) into components\summrise-desktop-electron. setup writes them in place;
|
|
1844
|
-
* update writes `*.new` so the swap script can atomically replace them. The
|
|
1845
|
-
* two flows used to each inline this block.
|
|
1846
|
-
*/
|
|
1847
|
-
/** @returns how many files were actually staged — 0 means the sources were not
|
|
1848
|
-
* present, which the caller MUST NOT report as "staged". */
|
|
1849
|
-
function stageDesktopShell(installDir, suffix) {
|
|
1850
|
-
const DESK_SRC = path.join(__dirname, "..", "summrise-desktop-electron", "src");
|
|
1851
|
-
if (!fs.existsSync(DESK_SRC))
|
|
1852
|
-
return 0;
|
|
1853
|
-
const desDst = path.join(installDir, "components", "summrise-desktop-electron", "src");
|
|
1854
|
-
fs.mkdirSync(desDst, { recursive: true });
|
|
1855
|
-
let staged = 0;
|
|
1856
|
-
for (const f of ["main.js", "preload.js", "url-policy.js"]) {
|
|
1857
|
-
const s = path.join(DESK_SRC, f);
|
|
1858
|
-
if (fs.existsSync(s)) {
|
|
1859
|
-
fs.copyFileSync(s, path.join(desDst, f + suffix));
|
|
1860
|
-
staged += 1;
|
|
1861
|
-
}
|
|
1862
|
-
}
|
|
1863
|
-
// icon.png/.ico go next to src/ (Electron loads from ../icon.png;
|
|
1864
|
-
// Windows Tray requires the .ico).
|
|
1865
|
-
for (const icon of ["icon.png", "icon.ico"]) {
|
|
1866
|
-
const iconSrc = path.join(__dirname, "..", "summrise-desktop-electron", icon);
|
|
1867
|
-
if (fs.existsSync(iconSrc))
|
|
1868
|
-
fs.copyFileSync(iconSrc, path.join(installDir, "components", "summrise-desktop-electron", icon));
|
|
1869
|
-
}
|
|
1870
|
-
// Fresh-install desktop fix: the shell is launched as `electron .`
|
|
1871
|
-
// (start-desktop.ps1), which resolves its entry ONLY via package.json
|
|
1872
|
-
// "main". stageDesktopShell used to ship src/*.js + icons but NO
|
|
1873
|
-
// package.json — so `electron .` had nothing to load, AND the installer's
|
|
1874
|
-
// Electron step (gated on Test-Path package.json) was skipped entirely,
|
|
1875
|
-
// leaving the desktop shell dead on every fresh box. Write the minimal
|
|
1876
|
-
// manifest here (setup + update paths; idempotent, not held open by the
|
|
1877
|
-
// running shell).
|
|
1878
|
-
const shellDir = path.join(installDir, "components", "summrise-desktop-electron");
|
|
1879
|
-
try {
|
|
1880
|
-
fs.writeFileSync(path.join(shellDir, "package.json"), JSON.stringify({
|
|
1881
|
-
name: "summrise-desktop-electron",
|
|
1882
|
-
version: "0.2.0",
|
|
1883
|
-
main: "src/main.js",
|
|
1884
|
-
private: true,
|
|
1885
|
-
}, null, 2), "utf8");
|
|
1886
|
-
}
|
|
1887
|
-
catch {
|
|
1888
|
-
/* best-effort — a failed write must not break staging */
|
|
1889
|
-
}
|
|
1890
|
-
return staged;
|
|
1891
|
-
}
|
|
1892
|
-
/**
|
|
1893
|
-
* Is a Windows process running? THREE answers, because two of them are not the same.
|
|
1894
|
-
*
|
|
1895
|
-
* `tasklist` failing (missing, refused, erroring) leaves stdout EMPTY, and every caller
|
|
1896
|
-
* here used to read that as "not running" — a claim of ABSENCE from a failed READ. That
|
|
1897
|
-
* is the fourth instance of this shape in this file (`statusReport`, `rollback status`,
|
|
1898
|
-
* `autostart status`), and it matters most for the AGENT itself: `summrise status` reporting
|
|
1899
|
-
* STOPPED for a device that is serving is the worst possible answer.
|
|
1900
|
-
*/
|
|
1901
|
-
function processRunning(image) {
|
|
1902
|
-
// No shell: `shell: true` concatenates argv into one cmd.exe string, so the unquoted
|
|
1903
|
-
// filter "IMAGENAME eq ..." was split at its spaces, tasklist rejected it, and the
|
|
1904
|
-
// probe always answered "no" even with the process running. (IMAGENAME also takes no
|
|
1905
|
-
// wildcard — the old `summrise-agent*` never matched.)
|
|
1906
|
-
const r = (0, child_process_1.spawnSync)("tasklist", ["/FI", `IMAGENAME eq ${image}`], {
|
|
1907
|
-
encoding: "utf8",
|
|
1908
|
-
});
|
|
1909
|
-
if (r.error || r.status === null || r.status !== 0)
|
|
1910
|
-
return "unknown";
|
|
1911
|
-
return String(r.stdout || "")
|
|
1912
|
-
.toLowerCase()
|
|
1913
|
-
.includes(image.toLowerCase())
|
|
1914
|
-
? "yes"
|
|
1915
|
-
: "no";
|
|
1916
|
-
}
|
|
1917
|
-
function svc(action) {
|
|
1918
|
-
// RETURN the status. It used to be discarded, which is why `stop` printed "stopped"
|
|
1919
|
-
// and exited 0 for a missing task or an access-denied, and why `start`/`restart` were
|
|
1920
|
-
// silent either way. `autostart` already checks; this is the same pattern.
|
|
1921
|
-
//
|
|
1922
|
-
// argv, NOT an `sh()` command string: both `${action}` and `${TASK}` used to be
|
|
1923
|
-
// interpolated UNQUOTED into a line cmd.exe parses, which is the door the `psArgv()`
|
|
1924
|
-
// incident comment above describes — a value carrying a space, `&`, `|`, `>` or `<`
|
|
1925
|
-
// is re-parsed as an OPERATOR before schtasks ever sees it, and `TASK` is a
|
|
1926
|
-
// literal only until somebody makes the task name configurable.
|
|
1927
|
-
// schtasks needs no shell feature (no pipe, no `&&`, no redirection) and `/Query`
|
|
1928
|
-
// below already spawns it this way, so the door closes with no behaviour change:
|
|
1929
|
-
// the same program receives the same argv.
|
|
1930
|
-
return (0, child_process_1.spawnSync)("schtasks", [`/${action}`, "/TN", TASK], {
|
|
1931
|
-
stdio: "inherit",
|
|
1932
|
-
});
|
|
1933
|
-
}
|
|
1934
|
-
/**
|
|
1935
|
-
* The scheduled task's State (`Running`, `Ready`, `Disabled`, …) or **null when it could not be read**.
|
|
1936
|
-
*
|
|
1937
|
-
* THREE STATES, NOT ONE. `autostart` states the rule and names the shape — "a failed READ reported as
|
|
1938
|
-
* evidence of ABSENCE" — which it calls the THIRD instance, after `statusReport` and `rollback status`.
|
|
1939
|
-
* Here `null` means "I could not ask", and callers report that as itself instead of as a verdict.
|
|
1940
|
-
*
|
|
1941
|
-
* `LOCALE` is why this asks `Get-ScheduledTask` rather than parsing `schtasks /Query`: the latter's headers
|
|
1942
|
-
* are localized, which is the reason `autostart` uses this call too.
|
|
1943
|
-
*/
|
|
1944
|
-
function taskState(name) {
|
|
1945
|
-
const r = (0, child_process_1.spawnSync)("powershell", [
|
|
1946
|
-
"-NoProfile",
|
|
1947
|
-
"-Command",
|
|
1948
|
-
`(Get-ScheduledTask -TaskName '${name}' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty State)`,
|
|
1949
|
-
], { encoding: "utf8" });
|
|
1950
|
-
const err = r && r.error;
|
|
1951
|
-
if (!r || err || r.status !== 0)
|
|
1952
|
-
return null; // spawn failed, or the read itself failed
|
|
1953
|
-
const s = String(r.stdout || "").trim();
|
|
1954
|
-
return s === "" ? null : s; // empty = the task is not there, which is also "no answer to Running?"
|
|
1955
|
-
}
|
|
1956
|
-
// Shared tunnel bootstrap: login (token or interactive) → create tunnel →
|
|
1957
|
-
// DNS route → write tunnel.yml. Used by `summrise setup --tunnel` and
|
|
1958
|
-
// `summrise tunnel install`.
|
|
1959
|
-
function initTunnel(hostname, regKey) {
|
|
1960
|
-
const cf = path.join(COMPONENTS_DIR, "cloudflared.exe");
|
|
1961
|
-
const cfg = path.join(ETC_DIR, "tunnel.yml");
|
|
1962
|
-
if (!fs.existsSync(cf)) {
|
|
1963
|
-
console.error("tunnel: cloudflared.exe not staged at", cf);
|
|
1964
|
-
// NOT "REINSTALL THE PACKAGE". The npm package carries NO boxed components BY DESIGN (that is what
|
|
1965
|
-
// keeps it ~6.7 MB), so a reinstall cannot stage this and the advice sent the operator in a circle.
|
|
1966
|
-
// `setup` is what stages it: resolveComponent fetches it from the RELEASE HOST and the agent's own
|
|
1967
|
-
// sha256 pin checks it again on the path it uses. Its doc records what this costs when nobody does —
|
|
1968
|
-
// "a device with no tunnel is INVISIBLE TO THE CONSOLE while looking perfectly healthy from inside."
|
|
1969
|
-
console.error(" run 'summrise setup' to stage it -- the component comes from the release host, not from the npm package.");
|
|
1970
|
-
process.exit(1);
|
|
1971
|
-
}
|
|
1972
|
-
const host = hostname || "d1.agent.saisi.online";
|
|
1973
|
-
// Login: (1) reg-key → gateway tunnel-token exchange (FULLY automatic —
|
|
1974
|
-
// the console's stored Cloudflare credential, no env var, no browser), or
|
|
1975
|
-
// (2) CLOUDFLARE_API_TOKEN env, or (3) interactive cloudflared browser
|
|
1976
|
-
// login as last resort.
|
|
1977
|
-
let token = process.env.CLOUDFLARE_API_TOKEN || "";
|
|
1978
|
-
if (!token && regKey) {
|
|
1979
|
-
console.log("tunnel: exchanging registration key for the Cloudflare API token...");
|
|
1980
|
-
try {
|
|
1981
|
-
const r = apiPost("/api/install/tunnel-token", { key: regKey });
|
|
1982
|
-
if (r && r.apiToken) {
|
|
1983
|
-
token = r.apiToken;
|
|
1984
|
-
console.log("tunnel: key exchanged (consumed once)");
|
|
1985
|
-
}
|
|
1986
|
-
else
|
|
1987
|
-
console.log("tunnel: tunnel-token exchange failed (" +
|
|
1988
|
-
(r && r.error ? r.error : "no token") +
|
|
1989
|
-
") -- falling back");
|
|
1990
|
-
}
|
|
1991
|
-
catch (e) {
|
|
1992
|
-
console.log("tunnel: exchange unavailable (" + e.message + ") -- falling back");
|
|
1993
|
-
}
|
|
1994
|
-
}
|
|
1995
|
-
const r1 = (0, child_process_1.spawnSync)(cf, token ? ["tunnel", "login", "--token", token] : ["tunnel", "login"], { stdio: "inherit" });
|
|
1996
|
-
if (r1.status !== 0) {
|
|
1997
|
-
console.error("tunnel: cloudflare login failed");
|
|
1998
|
-
process.exit(1);
|
|
1999
|
-
}
|
|
2000
|
-
const name = "summrise-agent-" + host.split(".")[0];
|
|
2001
|
-
(0, child_process_1.spawnSync)(cf, ["tunnel", "create", name], { stdio: "inherit" });
|
|
2002
|
-
const list = (0, child_process_1.spawnSync)(cf, ["tunnel", "list", "--name", name], { encoding: "utf8" })
|
|
2003
|
-
.stdout || "";
|
|
2004
|
-
const m = /([0-9a-fA-F]{8}-[0-9a-fA-F-]{27})/.exec(list);
|
|
2005
|
-
const tunnelId = m ? m[1] : null;
|
|
2006
|
-
if (!tunnelId) {
|
|
2007
|
-
console.error("tunnel: could not determine tunnel id");
|
|
2008
|
-
process.exit(1);
|
|
2009
|
-
}
|
|
2010
|
-
const r3 = (0, child_process_1.spawnSync)(cf, ["tunnel", "route", "dns", name, host], {
|
|
2011
|
-
stdio: "inherit",
|
|
2012
|
-
});
|
|
2013
|
-
if (r3.status !== 0) {
|
|
2014
|
-
console.error("tunnel: dns route failed");
|
|
2015
|
-
process.exit(1);
|
|
2016
|
-
}
|
|
2017
|
-
const cred = path.join(process.env.USERPROFILE || "", ".cloudflared", tunnelId + ".json");
|
|
2018
|
-
// Ingress follows the agent's configured bind port (custom ports 502
|
|
2019
|
-
// otherwise); DIR/config.yaml may not exist on fresh installs → default.
|
|
2020
|
-
const tunPort = agentPort(ETC_DIR);
|
|
2021
|
-
// THE INGRESS MUST NAME THE ADDRESS THE AGENT LISTENS ON, AND IT IS 127.0.0.1.
|
|
2022
|
-
//
|
|
2023
|
-
// This wrote `http://127.0.0.2:<port>` — and the agent's own provisioning
|
|
2024
|
-
// (`agent/src/tunnel.rs`) writes `127.0.0.1`, keeping a helper
|
|
2025
|
-
// (`ingress_service`) whose comment says it exists to "reach the agent where it
|
|
2026
|
-
// actually listens", and calling 127.0.0.2 "a dead address (502)". Two writers,
|
|
2027
|
-
// two answers, one file — and the LIVE DEVICE settles it: `netstat` shows the
|
|
2028
|
-
// listener on `127.0.0.1:18080`, and d1's own `etc\tunnel.yml` (written by the
|
|
2029
|
-
// agent) says `service: http://127.0.0.1:18080`. So this writer would have
|
|
2030
|
-
// repointed a working tunnel at a socket nobody holds.
|
|
2031
|
-
//
|
|
2032
|
-
// `allow-remote-config: false` IS NOT OPTIONAL EITHER, and it was missing here.
|
|
2033
|
-
// The agent writes it deliberately: cloudflared prefers a REMOTE config when one
|
|
2034
|
-
// exists, so a stale remote ingress keeps proxying to a dead address "no matter
|
|
2035
|
-
// what tunnel.yml says" (tunnel.rs). This writer silently re-enabled that.
|
|
2036
|
-
fs.writeFileSync(cfg, [
|
|
2037
|
-
"tunnel: " + tunnelId,
|
|
2038
|
-
"credentials-file: " + cred,
|
|
2039
|
-
"allow-remote-config: false",
|
|
2040
|
-
"ingress:",
|
|
2041
|
-
" - hostname: " + host,
|
|
2042
|
-
" service: http://127.0.0.1:" + tunPort,
|
|
2043
|
-
" - service: http_status:404",
|
|
2044
|
-
"",
|
|
2045
|
-
].join("\n"));
|
|
2046
|
-
console.log("tunnel: installed -- tunnel.yml written, agent spawns it on boot");
|
|
2047
|
-
console.log(" hostname:", host);
|
|
2048
|
-
}
|
|
2049
|
-
// exported: rollback version gate — a plain dotted triple only (the tgz URL
|
|
2050
|
-
// interpolates it; anything else could escape the /summrise-agent/ prefix). unit-tested.
|
|
2051
|
-
function rollbackVersionOk(v) {
|
|
2052
|
-
return /^\d+\.\d+\.\d+$/.test(v);
|
|
2053
|
-
}
|
|
2054
|
-
/**
|
|
2055
|
-
* TELL THE AGENT IT IS ABOUT TO BE STOPPED ON PURPOSE, before anything kills it.
|
|
2056
|
-
*
|
|
2057
|
-
* `summrise restart` and `summrise stop` end the process from OUTSIDE, so it writes no clean-exit marker —
|
|
2058
|
-
* and the run journal then reports the operator's own action as `crashed` whenever the revival
|
|
2059
|
-
* takes longer than the heartbeat window. The device has one place that knows how to mark it
|
|
2060
|
-
* (`runstate::mark_deliberate_stop`, behind `POST /api/run/mark-exit`), so the CLI asks rather than
|
|
2061
|
-
* writing the file itself: one rule, one implementation.
|
|
2062
|
-
*
|
|
2063
|
-
* BEST-EFFORT BY DESIGN: it runs BEFORE the kill, and a device whose agent is already gone must
|
|
2064
|
-
* still be stoppable. A failure is a WARNING, never a refusal to stop.
|
|
2065
|
-
*/
|
|
2066
|
-
function markDeliberateStop(reason = "stop") {
|
|
2067
|
-
// THE REASON DECIDES THE VERDICT. "update" makes the next start report `replaced` (the swap was
|
|
2068
|
-
// deliberate and normal) while "stop" reports `clean-exit`. Neither can be inferred from timing:
|
|
2069
|
-
// d1's boot history shows an update swap with a 61 s heartbeat gap while the boot task revives a
|
|
2070
|
-
// real crash within ~60 s — the durations overlap, so an unmarked update lands as a CRASH.
|
|
2071
|
-
const r = deviceApi("POST", "/api/run/mark-exit", { reason });
|
|
2072
|
-
if (!r.ok) {
|
|
2073
|
-
console.error(` (note: could not mark this stop as deliberate -- ${r.error}; the next start may report a crash)`);
|
|
2074
|
-
return;
|
|
2075
|
-
}
|
|
2076
|
-
if (r.body && r.body.marked === false) {
|
|
2077
|
-
// Nothing to mark: no run on record, or it already said it exited. Not an error.
|
|
2078
|
-
return;
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
const commands = {
|
|
2082
|
-
// `summrise setup` = PURE LOCAL install (no key, no tunnel, no cloud). The
|
|
2083
|
-
// gateway/tunnel are OPTIONAL extras configured LATER via the Settings
|
|
2084
|
-
// page or the optional flags below:
|
|
2085
|
-
// --reg-key <key> register the device with the gateway console now
|
|
2086
|
-
// --tunnel <host> also provision the (free) cloudflared tunnel
|
|
2087
|
-
setup(args) {
|
|
2088
|
-
const regOk = [];
|
|
2089
|
-
let i = args.indexOf("--reg-key");
|
|
2090
|
-
let regKey = i >= 0 ? args[i + 1] : process.env.SUMMRISE_REG_KEY;
|
|
2091
|
-
let ti = args.indexOf("--tunnel");
|
|
2092
|
-
let tunnelHost = ti >= 0 ? args[ti + 1] : "";
|
|
2093
|
-
let wantTunnel = args.includes("--tunnel") || !!process.env.CLOUDFLARE_API_TOKEN;
|
|
2094
|
-
// Device hostname for self-register: explicit --hostname, else THIS MACHINE'S NAME.
|
|
2095
|
-
//
|
|
2096
|
-
// **THE DEFAULT USED TO BE `d1.agent.saisi.online` — THE DEVELOPER'S OWN DEVICE — AND EVERY
|
|
2097
|
-
// FRESH INSTALL INHERITED IT** (measured 2026-09-27 on a second Windows machine). The operator
|
|
2098
|
-
// asked the obvious question: *"为什么注册还是d1,新设备不是应该是其他的吗"*. Everything
|
|
2099
|
-
// downstream followed from that one string: the console registered the new machine as `d1`, and
|
|
2100
|
-
// the tunnel was named `summrise-agent-d1` — colliding with the real one, which this file's own
|
|
2101
|
-
// self-register path reports as `(hostname/token conflict — resolve via the console Devices
|
|
2102
|
-
// page)`. **A DEFAULT THAT NAMES SOMEBODY ELSE'S MACHINE IS NOT A DEFAULT.**
|
|
2103
|
-
//
|
|
2104
|
-
// `COMPUTERNAME` is what Windows calls the machine; lower-cased it is a valid DNS label, and
|
|
2105
|
-
// the suffix keeps it under the zone the tunnel route is created in. `SUMMRISE_HOSTNAME` still
|
|
2106
|
-
// wins when set, and `--hostname` still wins over both.
|
|
2107
|
-
const hi = args.indexOf("--hostname");
|
|
2108
|
-
const machineName = (process.env.COMPUTERNAME || "device").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
2109
|
-
const deviceHost = hi >= 0
|
|
2110
|
-
? args[hi + 1]
|
|
2111
|
-
: process.env.SUMMRISE_HOSTNAME || `${machineName}.agent.saisi.online`;
|
|
2112
|
-
// review #1 (HIGH): the hostname write ran BEFORE the mkdirSync below —
|
|
2113
|
-
// on a FRESH machine DIR doesn't exist yet → ENOENT throw → setup died
|
|
2114
|
-
// having installed nothing. Ensure the dir first.
|
|
2115
|
-
fs.mkdirSync(ETC_DIR, { recursive: true });
|
|
2116
|
-
fs.mkdirSync(COMPONENTS_DIR, { recursive: true });
|
|
2117
|
-
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
|
|
2118
|
-
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
2119
|
-
fs.writeFileSync(HOSTNAME_FILE, deviceHost);
|
|
2120
|
-
// No key required for a local install — key/tunnel are optional extras.
|
|
2121
|
-
if (regKey) {
|
|
2122
|
-
// WAS: "setup: registering device with the gateway (--reg-key)" — FALSE on this
|
|
2123
|
-
// path. `regKey` has exactly ONE consumer, the Cloudflare token exchange inside
|
|
2124
|
-
// `initTunnel`, and that runs only under `--tunnel`. So `summrise setup --reg-key K`
|
|
2125
|
-
// without `--tunnel` printed that line and never used K at all; the device still
|
|
2126
|
-
// appeared in the console, but via the AGENT's own token-based self-register on
|
|
2127
|
-
// first boot, which needs no key — so the operator credited the key.
|
|
2128
|
-
//
|
|
2129
|
-
// It cannot be fixed by registering here: the device token is minted by the agent
|
|
2130
|
-
// on first boot, and the gateway's POST /api/register wants {key, name, hostname,
|
|
2131
|
-
// token}. So the line says what the key is actually for.
|
|
2132
|
-
console.log(wantTunnel
|
|
2133
|
-
? "setup: --reg-key will be exchanged for the tunnel token (--tunnel)"
|
|
2134
|
-
: "setup: --reg-key noted, but WITHOUT --tunnel it is not used — the device registers itself on first start with its own token. Pass --tunnel to use the key, or add the gateway later in the Settings page.");
|
|
2135
|
-
}
|
|
2136
|
-
else {
|
|
2137
|
-
// WAS: "setup: LOCAL install (no cloud)." — FALSE, and privacy-relevant.
|
|
2138
|
-
// The CLI does not write config.yaml at all; the AGENT creates it on first boot
|
|
2139
|
-
// from its embedded default, which sets platform.console_url to the public
|
|
2140
|
-
// console, and main.rs then POSTs {name, hostname, token} to
|
|
2141
|
-
// /api/devices/self-register at boot and every 6h. So a no-key install DOES
|
|
2142
|
-
// contact the cloud and appear in the console — which is what the very next lines
|
|
2143
|
-
// already say ("device registers on start"), so the operator was told both things
|
|
2144
|
-
// at once. The URL is NOT repeated here on purpose: it lives in the agent's
|
|
2145
|
-
// embedded default, and a second copy in a CLI message is a copy that drifts.
|
|
2146
|
-
console.log("setup: no key or tunnel configured — the device will still self-register with the console URL in its config on start.");
|
|
2147
|
-
console.log("setup: to keep it purely local, clear platform.console_url in config.yaml (unset = no cloud), or point it at your own gateway.");
|
|
2148
|
-
}
|
|
2149
|
-
fs.mkdirSync(DIR, { recursive: true });
|
|
2150
|
-
// Layout-v2 migration (ADR 0008): a re-setup on a pre-v2 device moves
|
|
2151
|
-
// the old root paths into their v2 homes before anything stages.
|
|
2152
|
-
// Idempotent (fresh installs no-op). Runs here — before the residue
|
|
2153
|
-
// cleanup below, which targets the NEW homes.
|
|
2154
|
-
try {
|
|
2155
|
-
const mig = psFile(migrateLayoutPs((0, exports.psq)(DIR), (0, exports.psq)(DATA_DIR)).join("\r\n"));
|
|
2156
|
-
if (!mig || mig.status !== 0)
|
|
2157
|
-
console.log("setup: layout migration had warnings (continuing)");
|
|
2158
|
-
}
|
|
2159
|
-
catch {
|
|
2160
|
-
console.log("setup: layout migration skipped (continuing)");
|
|
2161
|
-
}
|
|
2162
|
-
// ---- idempotent reinstall: clean every legacy residue BEFORE writing
|
|
2163
|
-
// anything (a re-run of `summrise setup` must leave a pristine install).
|
|
2164
|
-
// 1. Stop any running summrise processes (a live agent locks its exe and the
|
|
2165
|
-
// copy below would fail).
|
|
2166
|
-
console.log("setup: stopping existing summrise processes...");
|
|
2167
|
-
// Best-effort cleanup of things that usually do NOT exist, so send BOTH
|
|
2168
|
-
// streams to NUL. `2>NUL` alone left sc/reg/schtasks messages on STDOUT:
|
|
2169
|
-
// every install printed scary fake errors ("[SC] OpenService 失败 1060",
|
|
2170
|
-
// "错误: 系统找不到指定的文件。") that were really "nothing to clean".
|
|
2171
|
-
sh("cmd /c schtasks /End /TN SummriseAgent >NUL 2>&1");
|
|
2172
|
-
sh("taskkill /F /IM summrise-agent.exe 2>NUL");
|
|
2173
|
-
sh("taskkill /F /IM summrise-desktop.exe 2>NUL");
|
|
2174
|
-
sh("taskkill /F /IM summrise-tray.exe 2>NUL");
|
|
2175
|
-
// 2. Remove legacy scheduled tasks (SummrisePlaywright from old installs,
|
|
2176
|
-
// SummriseAgentTray) — SummriseAgent is re-registered below with -Force.
|
|
2177
|
-
sh("cmd /c schtasks /Delete /TN SummriseAgentTray /F >NUL 2>&1");
|
|
2178
|
-
sh("cmd /c schtasks /Delete /TN SummrisePlaywright /F >NUL 2>&1");
|
|
2179
|
-
// 3. Remove the legacy Cloudflared Windows service + EventLog source
|
|
2180
|
-
// (installed by the retired setup.ps1; the agent-supervised model
|
|
2181
|
-
// installs no service).
|
|
2182
|
-
sh("sc stop Cloudflared >NUL 2>&1");
|
|
2183
|
-
sh("sc delete Cloudflared >NUL 2>&1");
|
|
2184
|
-
sh("reg delete HKLM\\SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\Cloudflared /f >NUL 2>&1");
|
|
2185
|
-
// 4. Stale update-busy marker (a crashed update would lock updates).
|
|
2186
|
-
const BUSY = path.join(process.env.ProgramData || "C:\\ProgramData", "SummriseAgent", "update-busy");
|
|
2187
|
-
// `ps()` (argv) rather than `sh()`: BUSY is a PATH under %ProgramData%, and cmd
|
|
2188
|
-
// expands `%NAME%` in a value even when the value is quoted (see `sh()`).
|
|
2189
|
-
ps(`Remove-Item -Force -ErrorAction SilentlyContinue '${(0, exports.psq)(BUSY)}'`);
|
|
2190
|
-
// 5. Refresh the BOXED playwright bundle: delete the old tree first so a
|
|
2191
|
-
// removed package/version never leaves stale files behind.
|
|
2192
|
-
// round-163: kill the runner/bridge node processes FIRST — a running
|
|
2193
|
-
// node.exe holds its image file locked, the Remove-Item/Expand-Archive
|
|
2194
|
-
// pair silently skipped it, and the device was left with a playwright
|
|
2195
|
-
// dir WITHOUT node.exe (bridge could never spawn again; observed d1).
|
|
2196
|
-
// argv (`ps()`), because DIR is the install dir: an operator-chosen PATH, so cmd
|
|
2197
|
-
// could find a `%NAME%` in it — and this script's `|` and `{ }` are only data
|
|
2198
|
-
// because no shell parses the line at all.
|
|
2199
|
-
ps(`Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -like '*${(0, exports.psq)(DIR)}*playwright*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`);
|
|
2200
|
-
ps(`Remove-Item -Recurse -Force -ErrorAction SilentlyContinue '${(0, exports.psq)(PW_DIR)}'`);
|
|
2201
|
-
// 6. Legacy install dirs from retired installers (C:\summrise-agent /
|
|
2202
|
-
// D:\summrise-agent). If the registry now points at a DIFFERENT dir and a
|
|
2203
|
-
// legacy dir exists, it is a residue of the old channel — remove it
|
|
2204
|
-
// (the data that matters lives in %ProgramData%\Summrise; the old dirs
|
|
2205
|
-
// held programs + config only).
|
|
2206
|
-
for (const legacy of ["C:\\summrise-agent", "D:\\summrise-agent"]) {
|
|
2207
|
-
if (legacy !== DIR && fs.existsSync(legacy)) {
|
|
2208
|
-
console.log("setup: removing legacy install dir", legacy);
|
|
2209
|
-
// cmd-% literal: `legacy` is one of the two STRING LITERALS in the array on the
|
|
2210
|
-
// `for` line above, so this value cannot carry a `%` — reviewed, left on the cmd
|
|
2211
|
-
// line (the pin re-reads that array and checks it is literals all the way down).
|
|
2212
|
-
sh(`powershell -NoProfile -Command "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue '${(0, exports.psq)(legacy)}'"`);
|
|
2213
|
-
}
|
|
2214
|
-
}
|
|
2215
|
-
// 7. stage-brand: heal a stale desktop shortcut (a 2026-09-01 Summrise.lnk
|
|
2216
|
-
// launches the RETIRED Tauri exe with the old embedded icon) + drop
|
|
2217
|
-
// the retired orphans. Repair-only (helper checks link existence).
|
|
2218
|
-
console.log("setup: reconciling desktop shortcut (retired-exe repair)...");
|
|
2219
|
-
// argv (`ps()`), because BOTH values are paths (SCRIPTS_DIR, DESK_DIR): cmd expands
|
|
2220
|
-
// `%NAME%` inside a quoted region too (see `sh()`), so a `%` in the operator-chosen
|
|
2221
|
-
// install dir would arrive at PowerShell as a variable REFERENCE rather than as data.
|
|
2222
|
-
// This was the LAST acknowledged residual marker in this file, and the pin's cap is
|
|
2223
|
-
// ZERO now — a site that keeps a cmd line has to prove its value cannot carry a `%`.
|
|
2224
|
-
//
|
|
2225
|
-
// A PATH WITH NO `%` OR `"` IS UNAFFECTED: the script PowerShell ends up parsing is the
|
|
2226
|
-
// same text, now handed over directly instead of through cmd. A `"` IN ONE OF THE PATHS
|
|
2227
|
-
// CHANGES SHAPE, and that is the point: cmd reads `"` as a quote TOGGLE, so under the old
|
|
2228
|
-
// form it moved the quoted region (and the `.replace()` below rewrote the whole script
|
|
2229
|
-
// around it); as argv it is data like any other character. (`"` is not a legal character
|
|
2230
|
-
// in an NTFS name, so that one is the CLASS being closed rather than a reachable path — a
|
|
2231
|
-
// `%` in a directory name is legal, and was the reachable member.)
|
|
2232
|
-
//
|
|
2233
|
-
// AND THE `.replace(/"/g, '\\"')` THAT USED TO BE ON THIS LINE IS DELETED AS DEAD, not
|
|
2234
|
-
// merely redundant — it protected nothing and it rewrote the PAYLOAD. `\"` is not a cmd
|
|
2235
|
-
// escape (cmd's is `^`), so under cmd's own rules it did not stop the quoted region
|
|
2236
|
-
// ending at the first `"`; and the `.replace()` ran over the WHOLE joined script, so it
|
|
2237
|
-
// also hit the two double quotes that live INSIDE `deskShortcutRepairPs`'s single-quoted
|
|
2238
|
-
// PowerShell strings — the `.lnk` Arguments value. MEASURED against the builder: exactly
|
|
2239
|
-
// those two characters changed, so the shortcut would have been handed
|
|
2240
|
-
// `-File \"<path>\"` (a literal backslash PowerShell does not strip) had the escape
|
|
2241
|
-
// survived the trip. WHAT LET IT SURVIVE IS REASONED, NOT MEASURED HERE (there is no
|
|
2242
|
-
// cmd.exe on this box): node's CreateProcess quoting, cmd's `/s /c` verbatim tail and
|
|
2243
|
-
// powershell.exe's own `\"` unescaping unwound each other — exactly the "works by
|
|
2244
|
-
// accident" the deleted marker said about this line. With no shell in the path there is
|
|
2245
|
-
// no re-parsing layer left to protect against: `ps()` hands PowerShell the script
|
|
2246
|
-
// VERBATIM, the same way the update swap's `-File` path does.
|
|
2247
|
-
ps(deskShortcutRepairPs((0, exports.psq)(SCRIPTS_DIR), (0, exports.psq)(DESK_DIR), "Write-Host").join("; "));
|
|
2248
|
-
// C1: write the registry single source of truth (InstallDir; DataDir
|
|
2249
|
-
// defaults to %ProgramData%\Summrise). Everything else reads it back.
|
|
2250
|
-
// `regOk` is collected here and summarised at the end of setup: best-effort, but the
|
|
2251
|
-
// operator should be told once, with the consequence, rather than not at all.
|
|
2252
|
-
regOk.push(regWrite("InstallDir", DIR));
|
|
2253
|
-
// ...AND ECHO THE RESOLVED ONE, NOT THE DEFAULT. `resolveDataDir()` is registry-first and
|
|
2254
|
-
// `DATA_DIR` is its answer, so a device whose data dir was remapped has that path here. This used to
|
|
2255
|
-
// WRITE THE LITERAL DEFAULT back to the registry — while the tree below was created at `DATA_DIR` —
|
|
2256
|
-
// so the new tree landed at the remapped path and the registry then named the default, which is the
|
|
2257
|
-
// path the AGENT reads. The comment two lines up says "DataDir defaults to %ProgramData%\Summrise",
|
|
2258
|
-
// i.e. exactly what the code did not do: a default applies when nothing is set, not over a remap.
|
|
2259
|
-
regOk.push(regWrite("DataDir", DATA_DIR));
|
|
2260
|
-
// Pre-create the data dir tree (sessions/memory/logs — C1 separation).
|
|
2261
|
-
const DATA = DATA_DIR;
|
|
2262
|
-
for (const sub of ["sessions", "memory", "logs"]) {
|
|
2263
|
-
fs.mkdirSync(path.join(DATA, sub), { recursive: true });
|
|
2264
|
-
}
|
|
2265
|
-
if (!fs.existsSync(EXE_SRC)) {
|
|
2266
|
-
console.error("setup: summrise-agent.exe missing from package:", EXE_SRC);
|
|
2267
|
-
process.exit(1);
|
|
2268
|
-
}
|
|
2269
|
-
// The just-killed agent's file handle can lag a beat (and AV may scan the
|
|
2270
|
-
// fresh exe), so a bare copyFileSync raced EBUSY on reinstall — the update
|
|
2271
|
-
// swap retries this 12x; setup never did. Retry, re-killing a respawned
|
|
2272
|
-
// instance between attempts.
|
|
2273
|
-
{
|
|
2274
|
-
let copied = false;
|
|
2275
|
-
for (let i = 0; i < 12; i++) {
|
|
2276
|
-
try {
|
|
2277
|
-
fs.copyFileSync(EXE_SRC, EXE_DST);
|
|
2278
|
-
copied = true;
|
|
2279
|
-
break;
|
|
2280
|
-
}
|
|
2281
|
-
catch (e) {
|
|
2282
|
-
if (e?.code !== "EBUSY" &&
|
|
2283
|
-
e?.code !== "EPERM" &&
|
|
2284
|
-
e?.code !== "EACCES") {
|
|
2285
|
-
console.error("setup: exe copy failed:", e?.message || e);
|
|
2286
|
-
process.exit(1);
|
|
2287
|
-
}
|
|
2288
|
-
(0, child_process_1.spawnSync)("cmd", ["/c", "taskkill", "/F", "/IM", "summrise-agent.exe"], {
|
|
2289
|
-
stdio: "ignore",
|
|
2290
|
-
});
|
|
2291
|
-
try {
|
|
2292
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 700);
|
|
2293
|
-
}
|
|
2294
|
-
catch {
|
|
2295
|
-
/* best-effort sleep */
|
|
2296
|
-
}
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
if (!copied) {
|
|
2300
|
-
console.error("setup: FATAL -- could not replace summrise-agent.exe (locked). Close any running Summrise agent and re-run the installer.");
|
|
2301
|
-
process.exit(1);
|
|
2302
|
-
}
|
|
2303
|
-
}
|
|
2304
|
-
// B2: stage the boxed playwright bundle (node_modules ONLY — node.exe is
|
|
2305
|
-
// NOT bundled; the system node detected below runs it). Single small
|
|
2306
|
-
// artifact in the npm package, Summrise version-locked.
|
|
2307
|
-
const PW_ZIP = resolveComponent("summrise-playwright.zip", path.join(__dirname, "..", "summrise-playwright.zip"));
|
|
2308
|
-
if (PW_ZIP) {
|
|
2309
|
-
const pwDir = PW_DIR;
|
|
2310
|
-
fs.mkdirSync(pwDir, { recursive: true });
|
|
2311
|
-
// argv (`ps()`): PW_ZIP and COMPONENTS_DIR are paths, and cmd expands `%NAME%`
|
|
2312
|
-
// inside quotes (see `sh()`).
|
|
2313
|
-
ps(`Expand-Archive -Force -Path '${(0, exports.psq)(PW_ZIP)}' -DestinationPath '${(0, exports.psq)(COMPONENTS_DIR)}'`);
|
|
2314
|
-
// round-163: the whole point of the bundle is node.exe — VERIFY it
|
|
2315
|
-
// landed (a silently-missing copy killed the bridge forever on d1).
|
|
2316
|
-
// One retry, then fail loudly: a half-staged bundle is worse than none.
|
|
2317
|
-
if (!fs.existsSync(path.join(pwDir, "node.exe"))) {
|
|
2318
|
-
console.log("setup: node.exe missing after expand -- retrying once");
|
|
2319
|
-
ps(`Expand-Archive -Force -Path '${(0, exports.psq)(PW_ZIP)}' -DestinationPath '${(0, exports.psq)(COMPONENTS_DIR)}'`);
|
|
2320
|
-
}
|
|
2321
|
-
// "node_modules verified" was in the message while only node.exe was checked —
|
|
2322
|
-
// and node_modules is where the entry point the agent actually runs lives, so a
|
|
2323
|
-
// half-expanded bundle passed the check and failed at first use.
|
|
2324
|
-
const pwCli = path.join(pwDir, "node_modules", "@playwright", "mcp", "cli.js");
|
|
2325
|
-
if (fs.existsSync(path.join(pwDir, "node.exe")) && fs.existsSync(pwCli)) {
|
|
2326
|
-
console.log("setup: playwright bundle staged (node.exe + node_modules/@playwright/mcp verified)");
|
|
2327
|
-
}
|
|
2328
|
-
else {
|
|
2329
|
-
// NEVER fatal. The browser bundle is an OPTIONAL component, but this
|
|
2330
|
-
// branch hard-failed (exit 1) the whole agent install the moment
|
|
2331
|
-
// 1.2.311 started shipping the zip — its trigger is AV quarantining
|
|
2332
|
-
// playwright\node.exe (documented on d1). Drop the half-staged tree so
|
|
2333
|
-
// the agent cleanly sees "no bundle" and carry on: the agent core
|
|
2334
|
-
// does not need playwright.
|
|
2335
|
-
console.error("setup: WARNING -- playwright bundle staged WITHOUT node.exe (AV/lock interference?); browser tools stay disabled, agent install continues.");
|
|
2336
|
-
try {
|
|
2337
|
-
fs.rmSync(pwDir, { recursive: true, force: true });
|
|
2338
|
-
}
|
|
2339
|
-
catch {
|
|
2340
|
-
/* best-effort */
|
|
2341
|
-
}
|
|
2342
|
-
}
|
|
2343
|
-
}
|
|
2344
|
-
else {
|
|
2345
|
-
console.error("setup: WARNING -- the playwright bundle could not be obtained (not in the package, and the release host did not serve it); browser tools stay disabled.");
|
|
2346
|
-
}
|
|
2347
|
-
// Node runtime: the device has node (npm works), but the agent runs as
|
|
2348
|
-
// SYSTEM which may not see the user PATH — resolve the ABSOLUTE node path
|
|
2349
|
-
// now and record it in the registry so the agent can spawn it.
|
|
2350
|
-
const nodeWhich = (0, child_process_1.spawnSync)("where", ["node"], { encoding: "utf8" });
|
|
2351
|
-
const nodePath = nodeWhich.status === 0 && nodeWhich.stdout
|
|
2352
|
-
? nodeWhich.stdout.split(/\r?\n/)[0].trim()
|
|
2353
|
-
: "";
|
|
2354
|
-
if (nodePath) {
|
|
2355
|
-
try {
|
|
2356
|
-
regOk.push(regWrite("NodePath", nodePath));
|
|
2357
|
-
console.log("setup: system node detected:", nodePath);
|
|
2358
|
-
}
|
|
2359
|
-
catch {
|
|
2360
|
-
/* non-fatal */
|
|
2361
|
-
}
|
|
2362
|
-
}
|
|
2363
|
-
else {
|
|
2364
|
-
console.log("setup: WARNING -- node not found in PATH (browser tools need node)");
|
|
2365
|
-
}
|
|
2366
|
-
// C2: stage the boxed cloudflared binary into components/ (optional — local
|
|
2367
|
-
// mode works without it; only used when the user opts into public access).
|
|
2368
|
-
const CF_SRC = resolveComponent("cloudflared.exe", path.join(__dirname, "..", "cloudflared.exe"));
|
|
2369
|
-
if (CF_SRC) {
|
|
2370
|
-
fs.mkdirSync(COMPONENTS_DIR, { recursive: true });
|
|
2371
|
-
fs.copyFileSync(CF_SRC, path.join(COMPONENTS_DIR, "cloudflared.exe"));
|
|
2372
|
-
console.log("setup: cloudflared staged (tunnel optional -- `summrise tunnel install` to enable)");
|
|
2373
|
-
}
|
|
2374
|
-
else {
|
|
2375
|
-
// This branch used to be SILENT, and that silence is what the 2026-09-23
|
|
2376
|
-
// migration cost an hour of: a device with no cloudflared has no tunnel,
|
|
2377
|
-
// and a device with no tunnel cannot be reached from the console at all
|
|
2378
|
-
// while `summrise status` reports it healthy from inside.
|
|
2379
|
-
console.error("setup: WARNING -- cloudflared could NOT be staged (not in the package, and the release host did not serve it); this device will be unreachable from the console until the binary can be fetched.");
|
|
2380
|
-
}
|
|
2381
|
-
// P2-4: record the boxed-component versions (never fail-closed).
|
|
2382
|
-
writeBoxedVersions(DIR, path.join(__dirname, ".."));
|
|
2383
|
-
// round-330: Tauri summrise-desktop staging removed (retired).
|
|
2384
|
-
// stage-l: stage the Electron shell sources (main/preload) so the desktop
|
|
2385
|
-
// app picks up menu/command features on a fresh install too.
|
|
2386
|
-
const deskStaged = stageDesktopShell(DIR, "");
|
|
2387
|
-
// The claim follows the FACT. `stageDesktopShell` returns early when the sources
|
|
2388
|
-
// are not present, and this printed "sources staged" regardless — so a package that
|
|
2389
|
-
// did not ship them claimed they were staged.
|
|
2390
|
-
console.log(deskStaged > 0
|
|
2391
|
-
? `setup: summrise-desktop-electron sources staged (${deskStaged} files)`
|
|
2392
|
-
: "setup: summrise-desktop-electron sources NOT staged -- the package did not ship them; the desktop shell will not update");
|
|
2393
|
-
// …and the runtime those sources need. Sources without a runtime is a shell
|
|
2394
|
-
// that cannot start: no window, and (before this) nothing in the output said
|
|
2395
|
-
// why — the operator saw only an absent window.
|
|
2396
|
-
const elDist = ensureElectron();
|
|
2397
|
-
console.log(elDist
|
|
2398
|
-
? "setup: electron runtime ready for the desktop shell"
|
|
2399
|
-
: "setup: WARNING -- the electron runtime could not be obtained (not in the package, and the release host did not serve it); start-desktop.ps1 will not open a window until it can be fetched.");
|
|
2400
|
-
// ── Q9: leave a `summrise` COMMAND behind ──────────────────────────────────
|
|
2401
|
-
// `npx summrise-agent setup` installs the service and nothing puts the CLI on
|
|
2402
|
-
// PATH, so the very commands this output recommends (`summrise update`,
|
|
2403
|
-
// `summrise status`) are command-not-found — measured on d1 on 2026-09-23.
|
|
2404
|
-
// Installing THIS version by name keeps it deterministic and uses npm's own
|
|
2405
|
-
// shims rather than hand-written ones; when npm cannot, say the command.
|
|
2406
|
-
{
|
|
2407
|
-
const selfVer = String(require("../package.json").version || "");
|
|
2408
|
-
const pfx = (0, child_process_1.spawnSync)("npm", ["prefix", "-g"], {
|
|
2409
|
-
encoding: "utf8",
|
|
2410
|
-
shell: true,
|
|
2411
|
-
});
|
|
2412
|
-
const pre = pfx.status === 0 ? String(pfx.stdout || "").trim() : "";
|
|
2413
|
-
if (selfVer && pre) {
|
|
2414
|
-
// `shell: true` is REQUIRED here (npm on Windows is a `.cmd` shim, which a bare
|
|
2415
|
-
// spawnSync cannot execute) — and that shell JOINS THIS ARGV INTO ONE cmd.exe
|
|
2416
|
-
// LINE, the shape the `processRunning` comment above records for the tasklist
|
|
2417
|
-
// filter. `pre` is a PATH (npm's global prefix), so it is DOUBLE-QUOTED for the
|
|
2418
|
-
// cmd layer: unquoted, a prefix like `C:\Program Files\nodejs` was split at its
|
|
2419
|
-
// space and npm installed the CLI somewhere else while reporting success.
|
|
2420
|
-
//
|
|
2421
|
-
// QUOTING IS NOT ENOUGH FOR `pre`, because cmd expands `%NAME%` inside quotes —
|
|
2422
|
-
// so the path does not appear in this command line at all. `%SUMMRISE_NPM_PREFIX%`
|
|
2423
|
-
// does, and the value travels in the environment below. MEASURED on d1, through
|
|
2424
|
-
// npm.cmd ITSELF (the target here is a `.cmd` BATCH shim, and a batch file re-parses
|
|
2425
|
-
// its own arguments, so the /c line was not the only parser to clear):
|
|
2426
|
-
// `npm config get prefix --prefix "%SUMMRISE_PROBE2%"` -> C:\%ProgramFiles%\Summrise
|
|
2427
|
-
// `npm config get prefix --prefix "C:\%ProgramFiles%\Summrise"` -> C:\C:\Program Files\Summrise
|
|
2428
|
-
// The reference is expanded ONCE and what it produced is not re-scanned, so a prefix
|
|
2429
|
-
// literally named `C:\%ProgramFiles%\Summrise` arrives as exactly that, while the raw
|
|
2430
|
-
// interpolation of the same value does not; the quotes still hold an `&` inside one
|
|
2431
|
-
// argument. The pin requires every `%NAME%` on a cmd line to be defined in the same
|
|
2432
|
-
// call's `env`.
|
|
2433
|
-
//
|
|
2434
|
-
// cmd-% version: `${selfVer}` is package.json's own version — a semver, read from
|
|
2435
|
-
// the file the tests read too, so it cannot carry a `%` (and it must be quoted for
|
|
2436
|
-
// the cmd layer regardless, or npm reads an empty spec).
|
|
2437
|
-
const inst = (0, child_process_1.spawnSync)("npm", [
|
|
2438
|
-
"i",
|
|
2439
|
-
"-g",
|
|
2440
|
-
`"summrise-agent@${selfVer}"`,
|
|
2441
|
-
"--prefix",
|
|
2442
|
-
`"%SUMMRISE_NPM_PREFIX%"`,
|
|
2443
|
-
"--registry=https://registry.npmjs.org/",
|
|
2444
|
-
"--no-audit",
|
|
2445
|
-
"--no-fund",
|
|
2446
|
-
], {
|
|
2447
|
-
encoding: "utf8",
|
|
2448
|
-
shell: true,
|
|
2449
|
-
timeout: 300000,
|
|
2450
|
-
env: { ...process.env, SUMMRISE_NPM_PREFIX: pre },
|
|
2451
|
-
});
|
|
2452
|
-
console.log(inst.status === 0
|
|
2453
|
-
? `setup: ${selfVer} installed globally -- \`summrise status\` works from any shell`
|
|
2454
|
-
: `setup: WARNING -- could not install the CLI globally; run: npm i -g summrise-agent@${selfVer}`);
|
|
2455
|
-
}
|
|
2456
|
-
else {
|
|
2457
|
-
console.log(`setup: WARNING -- could not resolve npm's global prefix; run: npm i -g summrise-agent@${selfVer || "<version>"}`);
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
// ── the desktop shell's task: ONLOGON + a 5-minute watchdog ────────────────
|
|
2461
|
-
// `summrise autostart` has always said "the desktop task comes from the
|
|
2462
|
-
// installer", and the NSIS installer is RETIRED — so NOTHING created it. Measured
|
|
2463
|
-
// on d1: the window vanished with the console that launched it and
|
|
2464
|
-
// SummriseDesktop did not exist at all. The shape is the app's own, written to a
|
|
2465
|
-
// .ps1 so it can be read rather than escaped into one unreadable line:
|
|
2466
|
-
// ensure-desktop.ps1 exits when electron is already alive (the watchdog never
|
|
2467
|
-
// steals focus), the .vbs wrapper runs it with no console flash, and the 5-minute
|
|
2468
|
-
// repetition is what reborns a dead shell.
|
|
2469
|
-
try {
|
|
2470
|
-
const reg = path.join(SCRIPTS_DIR, "register-desktop-task.ps1");
|
|
2471
|
-
fs.writeFileSync(reg, desktopTaskPs((0, exports.psq)(DIR), (0, exports.psq)(LOGS_DIR)).join("\r\n") + "\r\n");
|
|
2472
|
-
// THE STATUS WAS DISCARDED AND THE SUCCESS LINE WAS UNCONDITIONAL, which is the defect the AGENT
|
|
2473
|
-
// task's check ten lines below names in its own comment: "audit #7: used to claim success
|
|
2474
|
-
// regardless". Worse, the `catch` below could not fire for this — `sh()` RETURNS, it does not
|
|
2475
|
-
// throw — so the only thing it ever caught was `writeFileSync`.
|
|
2476
|
-
//
|
|
2477
|
-
// ONE CALL, and its status is READ. The path is an ARGV ELEMENT now, and that is the fix for
|
|
2478
|
-
// both quoting attempts that came before it: the single-quoted version this shipped with
|
|
2479
|
-
// reached PowerShell with the quotes INCLUDED and died on the device with "unsupported path
|
|
2480
|
-
// format" for a path that was perfectly valid, and the double-quoted version that replaced it
|
|
2481
|
-
// still handed a PATH to cmd — where `%NAME%` expands inside the quotes (see `sh()`). argv
|
|
2482
|
-
// needs no quotes at all: PowerShell receives the path verbatim.
|
|
2483
|
-
//
|
|
2484
|
-
// A WARNING, NOT FATAL, unlike the agent task: headless installs have no SummriseDesktop, and the
|
|
2485
|
-
// registration script itself exits quietly when electron is already alive.
|
|
2486
|
-
const reg302 = (0, child_process_1.spawnSync)("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", reg], { stdio: "pipe" });
|
|
2487
|
-
if (reg302 && reg302.status === 0) {
|
|
2488
|
-
console.log("setup: SummriseDesktop registered (logon + a 5-minute watchdog) and started");
|
|
2489
|
-
}
|
|
2490
|
-
else {
|
|
2491
|
-
console.log(`setup: WARNING -- registering SummriseDesktop failed (status ${reg302 ? reg302.status : "spawn error"}); ` +
|
|
2492
|
-
`run: powershell -File "${SCRIPTS_DIR}\\register-desktop-task.ps1"`);
|
|
2493
|
-
}
|
|
2494
|
-
}
|
|
2495
|
-
catch {
|
|
2496
|
-
console.log(`setup: WARNING -- could not register SummriseDesktop; run: powershell -File "${SCRIPTS_DIR}\\register-desktop-task.ps1"`);
|
|
2497
|
-
}
|
|
2498
|
-
// Layout v2: write the start-desktop.ps1 launcher into scripts\ (the
|
|
2499
|
-
// SummriseDesktop onlogon task + desktop Summrise.lnk both call it). Was never
|
|
2500
|
-
// written before — a real gap that left the shell unlaunchable.
|
|
2501
|
-
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
|
|
2502
|
-
fs.writeFileSync(path.join(SCRIPTS_DIR, "start-desktop.ps1"), startDesktopPs((0, exports.psq)(DESK_DIR), (0, exports.psq)(LOGS_DIR)).join("\r\n"), "utf8");
|
|
2503
|
-
console.log("setup: scripts\\start-desktop.ps1 written");
|
|
2504
|
-
// Register boot-start task (SYSTEM) and kick it once; the agent's own
|
|
2505
|
-
// first-run flow registers the device with the console using the key.
|
|
2506
|
-
//
|
|
2507
|
-
// round-118: this used to be a raw `schtasks /Create /SC ONSTART`, which
|
|
2508
|
-
// inherits Task Scheduler defaults — a 72h execution limit that silently
|
|
2509
|
-
// kills the agent after 3 days (device goes dark until reboot), plus no
|
|
2510
|
-
// restart-on-failure. Register via ScheduledTask cmdlets with the full
|
|
2511
|
-
// hardening set instead (mirrors deploy/summrise-agent-setup.ps1):
|
|
2512
|
-
// - ExecutionTimeLimit 0 never kill the running task
|
|
2513
|
-
// - RestartOnFailure 8 x 1min scheduler retries after a crash
|
|
2514
|
-
// - battery-safe + StartWhenAvailable
|
|
2515
|
-
// - 5-min repetition watchdog IgnoreNew = no-op while running;
|
|
2516
|
-
// restarts within <=5 min if dead
|
|
2517
|
-
// - explicit config -Argument layout v2 (never the exe path)
|
|
2518
|
-
const regRes = ps(bootTaskPs((0, exports.psq)(EXE_DST), (0, exports.psq)(CFG_FILE), true).join("; "));
|
|
2519
|
-
if (!regRes || regRes.status !== 0) {
|
|
2520
|
-
console.error("setup: FATAL -- task registration failed (audit #7: used to claim success regardless).");
|
|
2521
|
-
process.exit(1);
|
|
2522
|
-
}
|
|
2523
|
-
// round-298 parity: a FRESH install must carry the release marker — without it
|
|
2524
|
-
// agent_update compares against the Cargo 1.0.x fallback and re-downloads + swaps
|
|
2525
|
-
// on every call.
|
|
2526
|
-
//
|
|
2527
|
-
// WRITTEN HERE, and that placement IS the fix: it used to sit ~20 lines ABOVE this
|
|
2528
|
-
// point while its comment claimed "after the exe copy + task registration (setup
|
|
2529
|
-
// provably succeeded)". The registration exits 1 on failure, so an aborted setup
|
|
2530
|
-
// left the marker claiming a version on an install with NO boot task — and
|
|
2531
|
-
// `summrise status` reported "(this device is current)" for a device that never starts.
|
|
2532
|
-
// A marker must not be written before the thing it attests to.
|
|
2533
|
-
writeReleaseMarker(DIR);
|
|
2534
|
-
// Control-panel entry for npm-path installs too (the NSIS writer owns
|
|
2535
|
-
// UninstallString on its installs; the helper never overwrites one).
|
|
2536
|
-
// Best-effort — a registry failure must not fail the install.
|
|
2537
|
-
try {
|
|
2538
|
-
const pkgVer = String(require("../package.json").version || "");
|
|
2539
|
-
const ureg = ps(uninstallRegBodyPs((0, exports.psq)(DIR), pkgVer).join("; "));
|
|
2540
|
-
console.log("setup: control-panel uninstall entry" +
|
|
2541
|
-
(ureg && ureg.status === 0
|
|
2542
|
-
? " ensured"
|
|
2543
|
-
: " (ensure failed -- uninstall via `summrise uninstall`)"));
|
|
2544
|
-
}
|
|
2545
|
-
catch {
|
|
2546
|
-
console.log("setup: control-panel entry skipped (uninstall via `summrise uninstall`)");
|
|
2547
|
-
}
|
|
2548
|
-
console.log("setup: installed to", DIR);
|
|
2549
|
-
// One summary rather than N scattered warnings, and it names the CONSEQUENCE
|
|
2550
|
-
// (path resolution disagrees) instead of just the failed call.
|
|
2551
|
-
if (regOk.includes(false)) {
|
|
2552
|
-
console.error("setup: WARNING -- the registry entry for this install is INCOMPLETE (" +
|
|
2553
|
-
regOk.filter((ok) => !ok).length +
|
|
2554
|
-
" of " +
|
|
2555
|
-
regOk.length +
|
|
2556
|
-
" writes failed). The agent resolves its paths from the registry, so it may look in the DEFAULT location instead of " +
|
|
2557
|
-
DIR +
|
|
2558
|
-
". Re-run `summrise setup` elevated if that matters.");
|
|
2559
|
-
}
|
|
2560
|
-
console.log("setup: device registers on start -- check the console Devices list");
|
|
2561
|
-
// Inbound firewall for the agent port (idempotent; inert when bound to
|
|
2562
|
-
// loopback, required for LAN clients otherwise). Best-effort, never
|
|
2563
|
-
// fail-closed — a locked-down box keeps working locally regardless.
|
|
2564
|
-
try {
|
|
2565
|
-
const fwPort = agentPort(ETC_DIR);
|
|
2566
|
-
const fw = ps(firewallPs(fwPort).join("; "));
|
|
2567
|
-
console.log("setup: firewall inbound TCP " +
|
|
2568
|
-
fwPort +
|
|
2569
|
-
(fw && fw.status === 0
|
|
2570
|
-
? " ensured"
|
|
2571
|
-
: " (ensure failed -- LAN clients may be blocked)"));
|
|
2572
|
-
}
|
|
2573
|
-
catch {
|
|
2574
|
-
console.log("setup: firewall ensure skipped (LAN clients may be blocked)");
|
|
2575
|
-
}
|
|
2576
|
-
// Optional: provision the tunnel in the same command (no second step).
|
|
2577
|
-
if (wantTunnel) {
|
|
2578
|
-
console.log("setup: provisioning cloudflare tunnel...");
|
|
2579
|
-
initTunnel(tunnelHost, regKey);
|
|
2580
|
-
}
|
|
2581
|
-
else {
|
|
2582
|
-
console.log("setup: no tunnel configured (local mode). Enable later with `summrise tunnel install <hostname>`.");
|
|
2583
|
-
}
|
|
2584
|
-
// START THE AGENT, BECAUSE `setup` IS WHERE AN OPERATOR EXPECTS THE THING TO BE RUNNING.
|
|
2585
|
-
// MEASURED 2026-09-27, ON A FRESH WINDOWS MACHINE: the documented journey was
|
|
2586
|
-
// `setup` -> `start` -> `desktop`, and the operator asked why it takes several commands.
|
|
2587
|
-
// `setup` already registers AND starts SummriseDesktop above; the AGENT task was the one
|
|
2588
|
-
// left to a separate command, and nothing about that split was useful to the person typing
|
|
2589
|
-
// it. `summrise start` remains for a stopped agent -- this removes the step from the
|
|
2590
|
-
// install, not the command.
|
|
2591
|
-
const agentRun = svc("Run");
|
|
2592
|
-
if (agentRun && agentRun.status === 0) {
|
|
2593
|
-
console.log("setup: SummriseAgent started");
|
|
2594
|
-
}
|
|
2595
|
-
else {
|
|
2596
|
-
console.log(`setup: WARNING -- could not start SummriseAgent (status ${agentRun ? agentRun.status : "spawn error"}); run: summrise start`);
|
|
2597
|
-
}
|
|
2598
|
-
},
|
|
2599
|
-
// ── monitor ────────────────────────────────────────────────────────────
|
|
2600
|
-
// The device's reachability instrument, in the terminal the operator already
|
|
2601
|
-
// works in. The panel draws the same numbers; this runs where the ssh session
|
|
2602
|
-
// is, needs no browser, and runs its live view until Ctrl+C.
|
|
2603
|
-
// async: `wait` blocks on probes (the dispatcher already awaits every command).
|
|
2604
|
-
async monitor(args) {
|
|
2605
|
-
const sub = String(args[0] || "list").toLowerCase();
|
|
2606
|
-
// `--expect <text>`: the page must CONTAIN this text, or the watch counts it as down — the
|
|
2607
|
-
// difference between a working UI and a login page that answers 200.
|
|
2608
|
-
const expectAt = args.indexOf("--expect");
|
|
2609
|
-
const expect = expectAt >= 0 ? String(args[expectAt + 1] || "") : "";
|
|
2610
|
-
const positional = args.filter((a, i) => i > 0 && a !== "--expect" && i !== expectAt + 1);
|
|
2611
|
-
if (expectAt >= 0 && !expect) {
|
|
2612
|
-
console.error('usage: summrise monitor add <host:port[/path]> --expect "<text>"');
|
|
2613
|
-
process.exit(1);
|
|
2614
|
-
}
|
|
2615
|
-
if (sub === "add" || sub === "rm" || sub === "remove") {
|
|
2616
|
-
const t = parseTargetArg(positional[0]);
|
|
2617
|
-
if (!t) {
|
|
2618
|
-
console.error("usage: summrise monitor add <host:port[/path]> (e.g. 192.168.1.1:80/ or 192.168.1.1:22)");
|
|
2619
|
-
process.exit(1);
|
|
2620
|
-
}
|
|
2621
|
-
if (sub === "add") {
|
|
2622
|
-
const r = deviceApi("POST", "/api/monitors/add", {
|
|
2623
|
-
host: t.host,
|
|
2624
|
-
port: t.port,
|
|
2625
|
-
path: t.path,
|
|
2626
|
-
expect,
|
|
2627
|
-
});
|
|
2628
|
-
if (!r.ok) {
|
|
2629
|
-
console.error(`monitor add: ${r.error}`);
|
|
2630
|
-
process.exit(1);
|
|
2631
|
-
}
|
|
2632
|
-
if (r.body && r.body.ok === false) {
|
|
2633
|
-
console.error(`monitor add: ${r.body.error}`);
|
|
2634
|
-
process.exit(1);
|
|
2635
|
-
}
|
|
2636
|
-
// Probe once so the operator sees a reading instead of "no readings yet"
|
|
2637
|
-
// for one 15 s cycle — the same courtesy the panel gives.
|
|
2638
|
-
deviceApi("POST", "/api/monitors/probe", { id: t.id });
|
|
2639
|
-
console.log(`watching ${t.id}${t.path ? " (HTTP GET, status code recorded)" : " (TCP connect)"}` +
|
|
2640
|
-
(expect ? ` requiring the body to contain "${expect}"` : ""));
|
|
2641
|
-
return;
|
|
2642
|
-
}
|
|
2643
|
-
const r = deviceApi("POST", "/api/monitors/remove", { id: t.id });
|
|
2644
|
-
if (!r.ok) {
|
|
2645
|
-
console.error(`monitor rm: ${r.error}`);
|
|
2646
|
-
process.exit(1);
|
|
2647
|
-
}
|
|
2648
|
-
console.log(r.body && r.body.removed
|
|
2649
|
-
? `stopped watching ${t.id}`
|
|
2650
|
-
: `monitor rm: ${t.id} was not being watched`);
|
|
2651
|
-
return;
|
|
2652
|
-
}
|
|
2653
|
-
if (sub === "probe") {
|
|
2654
|
-
// CHECK NOW. The device probes every 15 s on its own timer; this is for the moment after
|
|
2655
|
-
// a reboot or a config change, when waiting one cycle is the difference between "it is
|
|
2656
|
-
// back" and "I am still guessing". The AI has had this ability since it existed; the
|
|
2657
|
-
// operator's own terminal did not.
|
|
2658
|
-
const t = parseTargetArg(positional[0]);
|
|
2659
|
-
if (!t) {
|
|
2660
|
-
console.error("usage: summrise monitor probe <host:port[/path]>");
|
|
2661
|
-
process.exit(1);
|
|
2662
|
-
}
|
|
2663
|
-
const r = deviceApi("POST", "/api/tools/monitor_probe", { id: t.id });
|
|
2664
|
-
const payload = r.ok ? r.body : null;
|
|
2665
|
-
if (!r.ok) {
|
|
2666
|
-
console.error(`monitor probe: ${r.error}`);
|
|
2667
|
-
process.exit(1);
|
|
2668
|
-
}
|
|
2669
|
-
if (!payload || payload.ok !== true) {
|
|
2670
|
-
// The device refuses to probe what it is not watching (one rule, one place). The
|
|
2671
|
-
// CLI passes that on WITH the way out, instead of quietly adding a watch.
|
|
2672
|
-
const why = (payload && payload.error) || "the device refused the probe";
|
|
2673
|
-
console.error(`monitor probe: ${why}`);
|
|
2674
|
-
console.error(` to watch it: summrise monitor add ${t.id}`);
|
|
2675
|
-
process.exit(1);
|
|
2676
|
-
}
|
|
2677
|
-
const probe = payload.result && payload.result.probe;
|
|
2678
|
-
// The device sends the CRITERION with the answer (`expect`), so "no match" can name the
|
|
2679
|
-
// text it looked for — an unreadable verdict is a verdict nobody can act on.
|
|
2680
|
-
console.log(probeLine({
|
|
2681
|
-
id: t.id,
|
|
2682
|
-
expect: (payload.result && payload.result.expect) || null,
|
|
2683
|
-
}, probe, Date.now()));
|
|
2684
|
-
return;
|
|
2685
|
-
}
|
|
2686
|
-
if (sub !== "list") {
|
|
2687
|
-
console.error("usage: summrise monitor [list [--json] | add <host:port[/path]> [--expect <text>] | probe <host:port[/path]> | rm <host:port[/path]>]");
|
|
2688
|
-
process.exit(1);
|
|
2689
|
-
}
|
|
2690
|
-
const r = deviceApi("GET", "/api/monitors");
|
|
2691
|
-
if (!r.ok) {
|
|
2692
|
-
console.error(`monitor: ${r.error}`);
|
|
2693
|
-
process.exit(1);
|
|
2694
|
-
}
|
|
2695
|
-
if (args.includes("--json")) {
|
|
2696
|
-
// For a script: the device's numbers, one object, no colour and no prose. A device that
|
|
2697
|
-
// could not be reached is a NON-ZERO EXIT with the reason on stderr, never a JSON body
|
|
2698
|
-
// pretending everything is fine.
|
|
2699
|
-
const os = require("os");
|
|
2700
|
-
// ASCII-escaped for the same reason the request body is (see `asciiJson`): a PIPE is an
|
|
2701
|
-
// encoding boundary too. PowerShell decodes a child's output with the console code page
|
|
2702
|
-
// (CP936 on d1), so raw UTF-8 through a pipe came back as mojibake while the same text
|
|
2703
|
-
// printed directly read fine — the second face of this bug.
|
|
2704
|
-
console.log(asciiJson(monitorsJson({
|
|
2705
|
-
device: os.hostname(),
|
|
2706
|
-
askedAtMs: Date.now(),
|
|
2707
|
-
payload: r.body,
|
|
2708
|
-
only: null,
|
|
2709
|
-
}), 2));
|
|
2710
|
-
return;
|
|
2711
|
-
}
|
|
2712
|
-
const targets = (r.body && r.body.targets) || [];
|
|
2713
|
-
if (targets.length === 0) {
|
|
2714
|
-
console.log("watching nothing. `summrise monitor add 192.168.1.1:22` starts one;");
|
|
2715
|
-
console.log("add a path to check a web UI instead of a port: `summrise monitor add 192.168.1.1:80/`");
|
|
2716
|
-
return;
|
|
2717
|
-
}
|
|
2718
|
-
const now = Date.now();
|
|
2719
|
-
console.log(`watching ${targets.length} target(s), probed every ${r.body.interval_secs || 15}s:`);
|
|
2720
|
-
for (const t of targets)
|
|
2721
|
-
console.log(targetLine(t, now));
|
|
2722
|
-
},
|
|
2723
|
-
// Live view: redraw in place every few seconds until Ctrl+C. `summrise monitor
|
|
2724
|
-
// <host:port[/path]>` narrows it to one target and adds its outage log.
|
|
2725
|
-
// `summrise desktop` = put the window back, or say it is already there.
|
|
2726
|
-
//
|
|
2727
|
-
// WHY IT EXISTS. The shell is normally started by the SummriseDesktop task, but the LAUNCHER
|
|
2728
|
-
// dies with the console that started it — measured twice on 2026-09-23, both times arriving
|
|
2729
|
-
// as "the electron window is gone again" — and until now there was no command to ask for it, only a .ps1 path
|
|
2730
|
-
// an operator had to know. Idempotent by construction (the task's ensure-desktop.ps1 exits
|
|
2731
|
-
// when electron is alive, so a running shell is never disturbed), and it distinguishes the
|
|
2732
|
-
// three outcomes. "ok" without saying WHICH is how an operator ends up staring at a desktop.
|
|
2733
|
-
desktop() {
|
|
2734
|
-
const reg = path.join(SCRIPTS_DIR, "register-desktop-task.ps1");
|
|
2735
|
-
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
|
|
2736
|
-
if (!fs.existsSync(reg)) {
|
|
2737
|
-
// A machine that never ran setup still gets a working task: the same script setup
|
|
2738
|
-
// writes, from the same builder, so the two cannot drift.
|
|
2739
|
-
fs.writeFileSync(reg, desktopTaskPs((0, exports.psq)(DIR), (0, exports.psq)(LOGS_DIR)).join("\r\n") + "\r\n");
|
|
2740
|
-
}
|
|
2741
|
-
// THE START SCRIPT GOES IN A FILE, AND ONLY IT DECIDES WHETHER TO REGISTER. Spawning with
|
|
2742
|
-
// `shell: true` means cmd.exe, and **cmd splits a command on `&`** — the PowerShell call
|
|
2743
|
-
// operator in the middle of this logic cut the command in half on the device, so the `$t`
|
|
2744
|
-
// assignment never ran and it fell through to re-registering a task that already existed,
|
|
2745
|
-
// which then failed on the service account's principal. Two failures, one metacharacter.
|
|
2746
|
-
const start = path.join(SCRIPTS_DIR, "desktop-start.ps1");
|
|
2747
|
-
fs.writeFileSync(start, desktopStartPs(DIR).join("\r\n") + "\r\n");
|
|
2748
|
-
// The register script is WRITTEN here (a machine that never ran setup still gets a working
|
|
2749
|
-
// task, from the same builder setup uses) but NOT run: desktop-start.ps1 runs it only when
|
|
2750
|
-
// the task is genuinely missing.
|
|
2751
|
-
// ARGV, NO SHELL — the path is the reason. `shell: true` JOINS an args array into one cmd.exe
|
|
2752
|
-
// line and warns about it (DEP0190: "the arguments are not escaped, only concatenated" — the
|
|
2753
|
-
// warning the operator saw on the device the first time this command worked), and that line is
|
|
2754
|
-
// then re-parsed a second time: `%NAME%` expands even inside the double quotes the old
|
|
2755
|
-
// one-string form carried (see `sh()`), and this path lives under the install dir, which the
|
|
2756
|
-
// operator chooses. Passed as argv, the path reaches PowerShell VERBATIM as one element and
|
|
2757
|
-
// DEP0190 goes with the shell instead of being worked around.
|
|
2758
|
-
const r = (0, child_process_1.spawnSync)("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", start], { encoding: "utf8" });
|
|
2759
|
-
const out = String(r.stdout || "").trim();
|
|
2760
|
-
if (out.endsWith("already-running")) {
|
|
2761
|
-
console.log("desktop: the Summrise shell is ALREADY RUNNING (look for the window, or the tray icon)");
|
|
2762
|
-
return;
|
|
2763
|
-
}
|
|
2764
|
-
if (out.endsWith("started")) {
|
|
2765
|
-
console.log("desktop: started -- the window should be on your desktop now");
|
|
2766
|
-
return;
|
|
2767
|
-
}
|
|
2768
|
-
console.error(`desktop: the shell did NOT come up. Try it directly to see why: powershell -File "${path.join(SCRIPTS_DIR, "start-desktop.ps1")}"`);
|
|
2769
|
-
process.exit(1);
|
|
2770
|
-
},
|
|
2771
|
-
status() {
|
|
2772
|
-
// THE EXIT CODE IS 0 EVEN WHEN THE REPORT SAYS "UNKNOWN", AND THAT IS THE CONVENTION.
|
|
2773
|
-
//
|
|
2774
|
-
// `status` is asked for a REPORT; it produces one, and the verdict lives in the text. `tunnel`
|
|
2775
|
-
// exits 1 on the same unreadable process list (`:3655-3662`) because the thing IT was asked for —
|
|
2776
|
-
// a running tunnel — did not happen. The two are not inconsistent; they answer different
|
|
2777
|
-
// questions, and the difference is what the caller asked for rather than how bad the news is.
|
|
2778
|
-
//
|
|
2779
|
-
// A script that needs a VERDICT must not read this exit code. `monitor list --json` states its own
|
|
2780
|
-
// contract at `:2424-2426`: "A device that could not be reached is a NON-ZERO EXIT with the reason
|
|
2781
|
-
// on stderr, never a JSON body pretending everything is fine."
|
|
2782
|
-
//
|
|
2783
|
-
// NOT via shell: `shell: true` concatenates argv into one cmd.exe string,
|
|
2784
|
-
// so the unquoted filter "IMAGENAME eq …" was split at its spaces, tasklist
|
|
2785
|
-
// rejected it, and this ALWAYS printed STOPPED even with the agent running.
|
|
2786
|
-
// (IMAGENAME also takes no wildcard — the old `summrise-agent*` never matched.)
|
|
2787
|
-
const agentState = processRunning("summrise-agent.exe");
|
|
2788
|
-
// The report answers "where is this device, and is a swap still pending" —
|
|
2789
|
-
// the question that cost four hand reads (and one wrong conclusion) in the
|
|
2790
|
-
// incident. Gathering the facts is thin I/O; the SHAPE lives in the pure
|
|
2791
|
-
// `statusReport`, which is where the tests can reach it.
|
|
2792
|
-
let releaseVersion = null;
|
|
2793
|
-
try {
|
|
2794
|
-
const v = fs
|
|
2795
|
-
.readFileSync(path.join(ETC_DIR, ".summrise-release"), "utf8")
|
|
2796
|
-
.trim();
|
|
2797
|
-
if (v)
|
|
2798
|
-
releaseVersion = v;
|
|
2799
|
-
}
|
|
2800
|
-
catch {
|
|
2801
|
-
/* absent => unknown, never fabricated */
|
|
2802
|
-
}
|
|
2803
|
-
let updateMarkerMs = null;
|
|
2804
|
-
// Only ENOENT means "no marker". Any OTHER stat error (EACCES/EBUSY) used to land
|
|
2805
|
-
// here too and was reported as "update: none in flight" — a claim about an update
|
|
2806
|
-
// that may be running right now. This is the sibling of the `rollback --clear` fix:
|
|
2807
|
-
// the same catch treated a failure to READ as evidence of ABSENCE.
|
|
2808
|
-
let updateMarkerUnreadable = false;
|
|
2809
|
-
try {
|
|
2810
|
-
updateMarkerMs = fs.statSync(updateBusyPath()).mtimeMs;
|
|
2811
|
-
}
|
|
2812
|
-
catch (e) {
|
|
2813
|
-
if (e && e.code !== "ENOENT")
|
|
2814
|
-
updateMarkerUnreadable = true;
|
|
2815
|
-
}
|
|
2816
|
-
let packageVersion = "";
|
|
2817
|
-
try {
|
|
2818
|
-
packageVersion = String(require("../package.json").version || "");
|
|
2819
|
-
}
|
|
2820
|
-
catch {
|
|
2821
|
-
/* best-effort */
|
|
2822
|
-
}
|
|
2823
|
-
// What the TWO channels advertise, so `status` can answer "is this device
|
|
2824
|
-
// current" — the question every round of this project's log answered by
|
|
2825
|
-
// hand. Bounded HARD (3 s each): `status` is the command an operator runs
|
|
2826
|
-
// when something is already wrong, and a status that hangs on a network
|
|
2827
|
-
// blip is worse than one that says it could not check. `null` only when
|
|
2828
|
-
// BOTH are silent, which the report renders as "could NOT be checked" —
|
|
2829
|
-
// never as agreement.
|
|
2830
|
-
const latestVersion = latestReleaseVersion();
|
|
2831
|
-
for (const line of statusReport({
|
|
2832
|
-
agentRunning: agentState === "unknown" ? null : agentState === "yes",
|
|
2833
|
-
installDir: DIR,
|
|
2834
|
-
exeExists: fs.existsSync(EXE_DST),
|
|
2835
|
-
port: agentPort(ETC_DIR),
|
|
2836
|
-
releaseVersion,
|
|
2837
|
-
updateMarkerMs,
|
|
2838
|
-
updateMarkerUnreadable,
|
|
2839
|
-
packageVersion,
|
|
2840
|
-
latestVersion,
|
|
2841
|
-
nowMs: Date.now(),
|
|
2842
|
-
})) {
|
|
2843
|
-
console.log(line);
|
|
2844
|
-
}
|
|
2845
|
-
},
|
|
2846
|
-
start() {
|
|
2847
|
-
// CHECK IT, LIKE `stop` DOES. The comment on `svc` above says the return value exists because
|
|
2848
|
-
// discarding it made `start`/`restart` "silent either way" — and two of the four call sites still
|
|
2849
|
-
// discarded it, including this one, which is step 2 of every documented journey. An operator whose
|
|
2850
|
-
// agent never came up got exit 0 and no sentence at all.
|
|
2851
|
-
const r = svc("Run");
|
|
2852
|
-
if (r && r.status !== 0) {
|
|
2853
|
-
console.error(`summrise start: schtasks /Run failed (status ${r.status}) -- the agent is NOT running`);
|
|
2854
|
-
process.exit(1);
|
|
2855
|
-
}
|
|
2856
|
-
},
|
|
2857
|
-
stop() {
|
|
2858
|
-
// Say WHY before the process goes: see markDeliberateStop.
|
|
2859
|
-
markDeliberateStop();
|
|
2860
|
-
const r = svc("End");
|
|
2861
|
-
if (r && r.status !== 0) {
|
|
2862
|
-
// A NON-ZERO `/End` IS NOT EVIDENCE THE AGENT IS STILL RUNNING, and the mirror of this mistake is
|
|
2863
|
-
// already documented twice in this file. `restart` says it about the identical condition: "a task
|
|
2864
|
-
// that was not running has nothing to end, so a non-zero /End is the ordinary case". `svc`'s own
|
|
2865
|
-
// comment records the ORIGINAL bug, which was the opposite — "`stop` printed 'stopped' and exited 0
|
|
2866
|
-
// for a missing task or an access-denied". So the exit code answers neither question.
|
|
2867
|
-
//
|
|
2868
|
-
// ASK INSTEAD. `autostart` reads `Get-ScheduledTask ... -ExpandProperty State` and its comment names
|
|
2869
|
-
// this exact shape — "a failed READ reported as evidence of ABSENCE" — which it calls the THIRD
|
|
2870
|
-
// instance. This is the fourth, from the other side: an ordinary read taken as evidence of PRESENCE,
|
|
2871
|
-
// with "the agent may still be running" printed for a task that had already stopped.
|
|
2872
|
-
const st = taskState(TASK);
|
|
2873
|
-
if (st === null) {
|
|
2874
|
-
// Could not read it — say that, rather than guessing either way (the three-state rule).
|
|
2875
|
-
console.error(`summrise stop: schtasks /End failed (status ${r.status}) and the task's state could not be read`);
|
|
2876
|
-
process.exit(1);
|
|
2877
|
-
}
|
|
2878
|
-
if (st === "Running") {
|
|
2879
|
-
console.error(`summrise stop: schtasks /End failed (status ${r.status}) and the task is still Running`);
|
|
2880
|
-
process.exit(1);
|
|
2881
|
-
}
|
|
2882
|
-
console.error(`summrise stop: schtasks /End returned ${r.status}, but the task is not Running -- nothing to stop`);
|
|
2883
|
-
}
|
|
2884
|
-
console.log("stopped -- revives via 'summrise start' or the 5-min watchdog ('summrise autostart off' opts out of autostart)");
|
|
2885
|
-
},
|
|
2886
|
-
restart() {
|
|
2887
|
-
markDeliberateStop();
|
|
2888
|
-
// THE STOP IS REPORTED BUT NOT FATAL: a task that was not running has nothing to end, so a non-zero
|
|
2889
|
-
// `/End` is the ordinary case for "restart a stopped agent", and the operator asked for the START.
|
|
2890
|
-
const end = svc("End");
|
|
2891
|
-
if (end && end.status !== 0) {
|
|
2892
|
-
console.error(`summrise restart: schtasks /End failed (status ${end.status}) -- the old process may still be running`);
|
|
2893
|
-
}
|
|
2894
|
-
sh("timeout /t 2 >nul");
|
|
2895
|
-
const run = svc("Run");
|
|
2896
|
-
if (run && run.status !== 0) {
|
|
2897
|
-
console.error(`summrise restart: schtasks /Run failed (status ${run.status}) -- the agent is NOT running`);
|
|
2898
|
-
process.exit(1);
|
|
2899
|
-
}
|
|
2900
|
-
},
|
|
2901
|
-
// Boot switch: `summrise autostart on|off|status` (default status). Flips the
|
|
2902
|
-
// ENABLED flag on BOTH boot tasks (service + desktop shell) — this is the
|
|
2903
|
-
// only real "don't start at boot" control; `summrise stop` is one-shot and
|
|
2904
|
-
// the watchdog revives it. Missing task = skipped with a note (never
|
|
2905
|
-
// fatal — headless installs have no SummriseDesktop). State read via
|
|
2906
|
-
// Get-ScheduledTask (locale-independent enum, unlike schtasks /Query
|
|
2907
|
-
// headers which localize).
|
|
2908
|
-
autostart(args) {
|
|
2909
|
-
const sub = String(args[0] || "status").toLowerCase();
|
|
2910
|
-
if (sub === "status") {
|
|
2911
|
-
for (const t of exports.BOOT_TASKS) {
|
|
2912
|
-
const r = (0, child_process_1.spawnSync)("powershell", [
|
|
2913
|
-
"-NoProfile",
|
|
2914
|
-
"-Command",
|
|
2915
|
-
`(Get-ScheduledTask -TaskName '${t}' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty State -ErrorAction SilentlyContinue)`,
|
|
2916
|
-
], { encoding: "utf8" });
|
|
2917
|
-
// THREE STATES, NOT ONE. Every failure to READ the state landed on the same
|
|
2918
|
-
// output as a genuinely absent task: a missing powershell (spawn error), a
|
|
2919
|
-
// `Get-ScheduledTask` that threw on permissions, and "the task is not there" all
|
|
2920
|
-
// printed "(not installed)" — so an operator asking "is autostart on?" was sent
|
|
2921
|
-
// to re-run setup by an access-denied. This is the THIRD instance of the shape
|
|
2922
|
-
// fixed in `statusReport` and `rollback status`: a failed READ reported as
|
|
2923
|
-
// evidence of ABSENCE.
|
|
2924
|
-
const err = r && r.error;
|
|
2925
|
-
const st = r ? r.status : null;
|
|
2926
|
-
const s = String((r && r.stdout) || "").trim();
|
|
2927
|
-
if (err || st === null || st !== 0) {
|
|
2928
|
-
const why = err
|
|
2929
|
-
? err.message
|
|
2930
|
-
: String((r && r.stderr) || "").trim() || `exit ${st}`;
|
|
2931
|
-
console.log(`${t}: state UNKNOWN -- could not read it (${why})`);
|
|
2932
|
-
}
|
|
2933
|
-
else {
|
|
2934
|
-
console.log(`${t}: ${s || "(not installed)"}`);
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
|
-
return;
|
|
2938
|
-
}
|
|
2939
|
-
if (sub !== "on" && sub !== "off") {
|
|
2940
|
-
console.error("usage: summrise autostart <on|off|status>");
|
|
2941
|
-
process.exit(1);
|
|
2942
|
-
}
|
|
2943
|
-
let failed = false;
|
|
2944
|
-
let skipped = 0;
|
|
2945
|
-
for (const t of exports.BOOT_TASKS) {
|
|
2946
|
-
// ASK WHETHER THE TASK EXISTS FIRST. The comment above this loop has said
|
|
2947
|
-
// "Missing task = skipped with a note (never fatal -- headless installs have no
|
|
2948
|
-
// SummriseDesktop)" all along, and the code below it made a missing task FATAL — so a
|
|
2949
|
-
// headless install could not turn autostart off at all, and the advice printed with
|
|
2950
|
-
// it ("run summrise setup first") cannot help, because `summrise setup` never registers
|
|
2951
|
-
// SummriseDesktop (only the NSIS online installer does).
|
|
2952
|
-
const exists = (0, child_process_1.spawnSync)("schtasks", ["/Query", "/TN", t], { encoding: "utf8" })
|
|
2953
|
-
.status === 0;
|
|
2954
|
-
if (!exists) {
|
|
2955
|
-
console.log(`autostart: ${t} not installed -- skipped (headless install)`);
|
|
2956
|
-
skipped += 1;
|
|
2957
|
-
continue;
|
|
2958
|
-
}
|
|
2959
|
-
const argv = autostartArgv(t, sub);
|
|
2960
|
-
const r = (0, child_process_1.spawnSync)(argv[0], argv.slice(1), { stdio: "inherit" });
|
|
2961
|
-
if (!r || r.status !== 0) {
|
|
2962
|
-
console.error(`autostart: ${t} ${sub} FAILED on an existing task (status ${r ? r.status : "?"}) -- the change did not take`);
|
|
2963
|
-
failed = true;
|
|
2964
|
-
}
|
|
2965
|
-
else {
|
|
2966
|
-
console.log(`autostart: ${t} ${sub === "on" ? "enabled" : "disabled"}`);
|
|
2967
|
-
}
|
|
2968
|
-
}
|
|
2969
|
-
if (skipped === exports.BOOT_TASKS.length) {
|
|
2970
|
-
console.error("autostart: no boot tasks found -- nothing to switch. `summrise setup` registers the agent task; the desktop task comes from the installer.");
|
|
2971
|
-
process.exit(1);
|
|
2972
|
-
}
|
|
2973
|
-
// Only claim the durable outcome when every EXISTING task actually changed.
|
|
2974
|
-
if (sub === "off" && !failed)
|
|
2975
|
-
console.log("autostart: off -- tasks stay disabled across reboot until 'summrise autostart on'");
|
|
2976
|
-
if (failed)
|
|
2977
|
-
process.exit(1);
|
|
2978
|
-
},
|
|
2979
|
-
async update() {
|
|
2980
|
-
// THE CLI CANNOT DELIVER A VERSION IT DOES NOT CARRY (round 201, measured on the operator's device):
|
|
2981
|
-
// "update requested 1.2.438 -> 1.2.438", "copy ok=True", and the release unchanged — while `summrise status` kept saying
|
|
2982
|
-
// "THIS DEVICE IS BEHIND ... run 'summrise update'", i.e. the product pointed at a command that could not help. The reason
|
|
2983
|
-
// is the parity marker below: `<install>/.summrise-release` is stamped with THIS PACKAGE'S version, and the device's
|
|
2984
|
-
// `agent_update` reads that file as its LOCAL version. An older CLI therefore stamps the install with the version it
|
|
2985
|
-
// already has, the swap installs the same build, and nothing moves. Refuse before touching the device, and say the
|
|
2986
|
-
// thing that works — the release flow has always been two steps.
|
|
2987
|
-
const latest = latestReleaseVersion();
|
|
2988
|
-
const selfVersion = String(require("../package.json").version || "");
|
|
2989
|
-
if (latest && selfVersion && isBehind(selfVersion, latest)) {
|
|
2990
|
-
console.error(`update: this CLI is ${selfVersion} and the release channel has ${latest}.` +
|
|
2991
|
-
"\n Updating from here would stamp the device with " +
|
|
2992
|
-
selfVersion +
|
|
2993
|
-
" and change nothing, because the device reads <install>/.summrise-release (written by this package) as its version." +
|
|
2994
|
-
"\n Install the new CLI first, then update again:" +
|
|
2995
|
-
"\n npm i -g summrise-agent" +
|
|
2996
|
-
"\n summrise update");
|
|
2997
|
-
process.exit(1);
|
|
2998
|
-
}
|
|
2999
|
-
// AND THE SAME REFUSAL WITHOUT THE NETWORK. The guard above cannot fire when the CDN is
|
|
3000
|
-
// unreadable, and the defect it exists for is exactly what happens then: an update that stamps the
|
|
3001
|
-
// install with a version it already has, swaps in the same build, and reports success while
|
|
3002
|
-
// `status` keeps saying the device is behind.
|
|
3003
|
-
let deviceNow = "";
|
|
3004
|
-
try {
|
|
3005
|
-
deviceNow = fs
|
|
3006
|
-
.readFileSync(path.join(ETC_DIR, ".summrise-release"), "utf8")
|
|
3007
|
-
.trim();
|
|
3008
|
-
}
|
|
3009
|
-
catch {
|
|
3010
|
-
// No marker is not a no-op; the update below writes one.
|
|
3011
|
-
}
|
|
3012
|
-
if (updateWouldNotMove(deviceNow, selfVersion)) {
|
|
3013
|
-
console.error(`update: this CLI is ${selfVersion} and the device is ALREADY on ${deviceNow}.` +
|
|
3014
|
-
"\n Updating from here would stamp the device with the version it already has and change nothing," +
|
|
3015
|
-
"\n because the device reads <install>/.summrise-release (written by this package) as its version." +
|
|
3016
|
-
"\n If the device is meant to be newer, this CLI is too old to deliver it: install the new one first." +
|
|
3017
|
-
"\n npm i -g summrise-agent" +
|
|
3018
|
-
"\n summrise update");
|
|
3019
|
-
process.exit(1);
|
|
3020
|
-
}
|
|
3021
|
-
// npm audit #10: no mutual exclusion — two updates (or setup racing a
|
|
3022
|
-
// swap) interleave Copy-Item on *.new, leaving a half-written exe "ok".
|
|
3023
|
-
// setup REMOVES the marker; update now CREATES it (refuse if <10 min
|
|
3024
|
-
// old); the swap script clears it after restart.
|
|
3025
|
-
// MEDIUM npm audit: use 'wx' exclusive-create so two racing updaters
|
|
3026
|
-
// cannot BOTH pass the freshness check — the second openSync throws
|
|
3027
|
-
// EEXIST and the WMI swap is never launched concurrently.
|
|
3028
|
-
const BUSYM = updateBusyPath();
|
|
3029
|
-
try {
|
|
3030
|
-
fs.mkdirSync(path.dirname(BUSYM), { recursive: true });
|
|
3031
|
-
const fd = fs.openSync(BUSYM, "wx");
|
|
3032
|
-
fs.writeSync(fd, String(Date.now()));
|
|
3033
|
-
fs.closeSync(fd);
|
|
3034
|
-
}
|
|
3035
|
-
catch (e) {
|
|
3036
|
-
if (e?.code === "EEXIST") {
|
|
3037
|
-
// Exists → check freshness (the owner may have died mid-swap).
|
|
3038
|
-
try {
|
|
3039
|
-
const st = fs.statSync(BUSYM);
|
|
3040
|
-
if (busyIsFresh(st.mtimeMs, Date.now())) {
|
|
3041
|
-
console.error("update: another update looks in progress (" +
|
|
3042
|
-
BUSYM +
|
|
3043
|
-
" <10 min old) -- wait, or delete the marker after a mid-swap reboot");
|
|
3044
|
-
process.exit(1);
|
|
3045
|
-
}
|
|
3046
|
-
// Stale marker — overwrite it.
|
|
3047
|
-
fs.writeFileSync(BUSYM, String(Date.now()));
|
|
3048
|
-
}
|
|
3049
|
-
catch {
|
|
3050
|
-
console.error("update: another update looks in progress (cannot stat " +
|
|
3051
|
-
BUSYM +
|
|
3052
|
-
")");
|
|
3053
|
-
process.exit(1);
|
|
3054
|
-
}
|
|
3055
|
-
}
|
|
3056
|
-
else {
|
|
3057
|
-
throw e; // a real FS error — do not proceed
|
|
3058
|
-
}
|
|
3059
|
-
}
|
|
3060
|
-
// THE RECEIPT, before anything irreversible happens. Everything past this
|
|
3061
|
-
// point ends with the agent being killed — which is the DOCUMENTED success
|
|
3062
|
-
// signal ("the connection drops for ~10 s"), and therefore indistinguishable
|
|
3063
|
-
// from a transport failure that never ran this command at all. One line in
|
|
3064
|
-
// the log the swap itself appends to is what separates the two cases for
|
|
3065
|
-
// whoever reads the device afterwards.
|
|
3066
|
-
let fromVersion = "";
|
|
3067
|
-
try {
|
|
3068
|
-
fromVersion = fs
|
|
3069
|
-
.readFileSync(path.join(ETC_DIR, ".summrise-release"), "utf8")
|
|
3070
|
-
.trim();
|
|
3071
|
-
}
|
|
3072
|
-
catch {
|
|
3073
|
-
/* unknown */
|
|
3074
|
-
}
|
|
3075
|
-
let toVersion = "";
|
|
3076
|
-
try {
|
|
3077
|
-
toVersion = String(require("../package.json").version || "");
|
|
3078
|
-
}
|
|
3079
|
-
catch {
|
|
3080
|
-
/* best-effort */
|
|
3081
|
-
}
|
|
3082
|
-
console.log(`update: ${fromVersion || "unknown"} -> ${toVersion || "unknown"} -- staging, the connection will drop`);
|
|
3083
|
-
// `ps()` RETURNS its spawn result, it does not throw (npm audit #7 made it return
|
|
3084
|
-
// precisely because a discarded result printed success anyway). So this try/catch
|
|
3085
|
-
// could never fire and a failed receipt write was SILENT — and the receipt is the
|
|
3086
|
-
// ONE artifact that separates "the CLI ran but the swap did not" from "nothing ran
|
|
3087
|
-
// at all" (the round-17 incident). Silent here means the log looks like the command
|
|
3088
|
-
// never reached the device. Best-effort, so it warns rather than aborting.
|
|
3089
|
-
const receipt = ps(updateReceiptPs((0, exports.psq)(DATA_DIR), fromVersion || "unknown", toVersion || "unknown").join("; "));
|
|
3090
|
-
// MARK THE SWAP AS DELIBERATE, at the same instant as the receipt and for the same reason:
|
|
3091
|
-
// past this point the agent is killed from outside and writes no goodbye of its own, so the
|
|
3092
|
-
// next start has to GUESS — and the guess is wrong whenever the swap is slow (d1 measured a
|
|
3093
|
-
// 61 s gap, the boot task revives a crash in ~60 s, so the two overlap and genuine updates were
|
|
3094
|
-
// filed as CRASHES). With the mark, the next start reads `reason=update` and reports `replaced`.
|
|
3095
|
-
markDeliberateStop("update");
|
|
3096
|
-
if (!receipt || receipt.status !== 0 || receipt.error) {
|
|
3097
|
-
console.error("update: WARNING -- could not write the receipt to summrise-update.log (" +
|
|
3098
|
-
((receipt && receipt.error && receipt.error.message) ||
|
|
3099
|
-
`exit ${receipt ? receipt.status : "?"}`) +
|
|
3100
|
-
"); if this swap fails, the log will not distinguish it from a command that never arrived");
|
|
3101
|
-
}
|
|
3102
|
-
// Swap the exe in-place: stop -> replace (with retry; the running agent
|
|
3103
|
-
// locks its own file) -> start.
|
|
3104
|
-
//
|
|
3105
|
-
// THE MARKER IS ALREADY CREATED at this point, and the only things that
|
|
3106
|
-
// release it are the WMI-failure handler below and the swap script's own
|
|
3107
|
-
// cleanup. Staging in between is NOT guarded by either: a full disk, an
|
|
3108
|
-
// antivirus lock or a permission error on the copy throws straight out to
|
|
3109
|
-
// the top level, leaving the marker behind — and the NEXT `summrise update`
|
|
3110
|
-
// then refuses for ten minutes citing an update that never started, while
|
|
3111
|
-
// the operator sees only a stack trace. Its neighbours
|
|
3112
|
-
// (`writeBoxedVersions`, `writeReleaseMarker`) are best-effort for the same
|
|
3113
|
-
// reason; this is the one region where a throw strands a LOCK.
|
|
3114
|
-
try {
|
|
3115
|
-
if (!fs.existsSync(EXE_SRC)) {
|
|
3116
|
-
console.error("exe missing from package:", EXE_SRC);
|
|
3117
|
-
throw new Error("exe missing from package: " + EXE_SRC);
|
|
3118
|
-
}
|
|
3119
|
-
fs.mkdirSync(DIR, { recursive: true });
|
|
3120
|
-
fs.copyFileSync(EXE_SRC, path.join(DIR, "summrise-agent.new.exe"));
|
|
3121
|
-
// stage-l: ship the Electron desktop shell's main/preload alongside —
|
|
3122
|
-
// the desktop app (components\summrise-desktop-electron) loads these sources;
|
|
3123
|
-
// without the sync, new menu/command features never reach the device.
|
|
3124
|
-
// (setup writes in place; update stages *.new for the atomic swap.)
|
|
3125
|
-
stageDesktopShell(DIR, ".new");
|
|
3126
|
-
}
|
|
3127
|
-
catch (e) {
|
|
3128
|
-
try {
|
|
3129
|
-
fs.unlinkSync(BUSYM);
|
|
3130
|
-
}
|
|
3131
|
-
catch {
|
|
3132
|
-
/* never created, or already gone */
|
|
3133
|
-
}
|
|
3134
|
-
console.error("update: staging failed before the swap (" +
|
|
3135
|
-
(e && e.message ? e.message : e) +
|
|
3136
|
-
")");
|
|
3137
|
-
console.error("update: nothing was swapped and the in-progress marker was released -- safe to re-run");
|
|
3138
|
-
process.exit(1);
|
|
3139
|
-
}
|
|
3140
|
-
// P2-4: refresh the boxed-component manifest from the staged package +
|
|
3141
|
-
// the current install dir (best-effort, never fail-closed).
|
|
3142
|
-
writeBoxedVersions(DIR, path.join(__dirname, ".."));
|
|
3143
|
-
const q = DIR.replace(/'/g, "''");
|
|
3144
|
-
const qd = DATA_DIR.replace(/'/g, "''");
|
|
3145
|
-
const log = `Out-File '${qd}\\logs\\summrise-update.log' -Append`;
|
|
3146
|
-
// round-143: write the run-hidden.vbs wrapper next to node.exe, so the
|
|
3147
|
-
// SummrisePlaywright scheduled task can launch node.exe without flashing a
|
|
3148
|
-
// visible cmd window. Idempotent — overwrites any existing copy.
|
|
3149
|
-
// Layout v2: launchers live in scripts\, the runtime stays in
|
|
3150
|
-
// components\playwright\. The old-layout gate keeps migrating devices
|
|
3151
|
-
// refreshed too (migration carries the files over regardless).
|
|
3152
|
-
const pwDir = PW_DIR;
|
|
3153
|
-
const vbsPath = path.join(SCRIPTS_DIR, "run-hidden.vbs");
|
|
3154
|
-
// round-246 (browser-display audit C3) + round-257 + round-263:
|
|
3155
|
-
// ONE-BROWSER — the AI must drive the SAME browser the user watches:
|
|
3156
|
-
// the Electron desktop embedded WebContentsView (CDP 9333). The
|
|
3157
|
-
// SummrisePlaywright task used to launch playwright-mcp with --headless (a
|
|
3158
|
-
// PRIVATE chromium nobody sees). The task now goes through a probe
|
|
3159
|
-
// launcher that attaches to the DESKTOP view (9333) and falls back to a
|
|
3160
|
-
// private headless only when the desktop is down (agent restart window).
|
|
3161
|
-
// The bridge chromium (9223) tier was removed in round-263.
|
|
3162
|
-
const probePath = path.join(SCRIPTS_DIR, "playwright-probe.ps1");
|
|
3163
|
-
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
|
|
3164
|
-
if (fs.existsSync(pwDir) || fs.existsSync(path.join(DIR, "playwright"))) {
|
|
3165
|
-
// round-143: ASCII-only VBS (no em-dash, no Unicode). VBScript on
|
|
3166
|
-
// Windows uses the system locale; non-ASCII in comments corrupts the
|
|
3167
|
-
// file and causes "unterminated string constant" (800A0409). Use chr(34)
|
|
3168
|
-
// to produce literal double-quotes without string-escaping issues.
|
|
3169
|
-
fs.writeFileSync(vbsPath, [
|
|
3170
|
-
"Dim sh,cmd,i",
|
|
3171
|
-
'Set sh=CreateObject("WScript.Shell")',
|
|
3172
|
-
'cmd=chr(34) & WScript.Arguments(0) & chr(34) & " " & chr(34) & WScript.Arguments(1) & chr(34)',
|
|
3173
|
-
"For i=2 To WScript.Arguments.Count-1",
|
|
3174
|
-
' cmd=cmd & " " & WScript.Arguments(i)',
|
|
3175
|
-
"Next",
|
|
3176
|
-
"sh.Run cmd,0,False",
|
|
3177
|
-
].join("\r\n"));
|
|
3178
|
-
// round-246 (C3) + round-257 + round-263: the probe launcher
|
|
3179
|
-
// (playwrightProbePs, unit-tested).
|
|
3180
|
-
fs.writeFileSync(probePath, playwrightProbePs().join("\r\n"));
|
|
3181
|
-
}
|
|
3182
|
-
// Layout v2: write the start-desktop.ps1 launcher into scripts\ (the
|
|
3183
|
-
// SummriseDesktop onlogon task + desktop Summrise.lnk both call it). Was never
|
|
3184
|
-
// written before — a real gap that left the shell unlaunchable. Written
|
|
3185
|
-
// unconditionally: on a pre-v2 device components\ appears only after
|
|
3186
|
-
// the swap script's migration, and a headless install simply never
|
|
3187
|
-
// calls the launcher (shortcut repair is Test-Path guarded anyway).
|
|
3188
|
-
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
|
|
3189
|
-
fs.writeFileSync(path.join(SCRIPTS_DIR, "start-desktop.ps1"), startDesktopPs((0, exports.psq)(DESK_DIR), (0, exports.psq)(LOGS_DIR)).join("\r\n"), "utf8");
|
|
3190
|
-
// round-298: record the release version on a PROVABLY successful swap
|
|
3191
|
-
// so agent_update (which reads <install>/.summrise-release as its local
|
|
3192
|
-
// version) reports up_to_date instead of re-swapping every call.
|
|
3193
|
-
let relVer = "";
|
|
3194
|
-
try {
|
|
3195
|
-
relVer = String(require("../package.json").version || "");
|
|
3196
|
-
}
|
|
3197
|
-
catch {
|
|
3198
|
-
/* best-effort */
|
|
3199
|
-
}
|
|
3200
|
-
const script = [
|
|
3201
|
-
`"[$(Get-Date -Format o)] update start" | ${log}`,
|
|
3202
|
-
// Layout v2 FIRST: repoint the boot task at the explicit config path
|
|
3203
|
-
// BEFORE touching anything (fail-closed — a config-path argument boots
|
|
3204
|
-
// old AND new agents alike, so aborting here leaves the old version
|
|
3205
|
-
// running untouched). Without this the moved config strands the boot.
|
|
3206
|
-
`try { ${bootTaskPs(`${q}\\summrise-agent.exe`, `${q}\\etc\\config.yaml`, false).join("; ")} } catch { "[$(Get-Date -Format o)] task repoint FAILED: $($_.Exception.Message)" | ${log}; try { Remove-Item -Force (${busyMarkerPs()}) } catch {}; exit 1 }`,
|
|
3207
|
-
`"[$(Get-Date -Format o)] task repointed at etc\\config.yaml" | ${log}`,
|
|
3208
|
-
// Layout-v2 migration (ADR 0008): move pre-v2 root paths into their
|
|
3209
|
-
// v2 homes. Best-effort per item; the gate below is fail-closed.
|
|
3210
|
-
...migrateLayoutPs(q, qd),
|
|
3211
|
-
// Fail-closed gate: the new agent reads ONLY the v2 homes. A missing
|
|
3212
|
-
// config/hostname here means migration failed — do NOT swap (the old
|
|
3213
|
-
// exe keeps running the old layout until the next update).
|
|
3214
|
-
`if ((-not (Test-Path '${q}\\etc\\config.yaml')) -or (-not (Test-Path '${q}\\etc\\summrise-agent.hostname'))) { "[$(Get-Date -Format o)] migration gate FAILED (etc\\config.yaml/hostname missing) -- aborting, old version keeps running" | ${log}; try { Remove-Item -Force (${busyMarkerPs()}) } catch {}; exit 1 }`,
|
|
3215
|
-
// A running exe cannot be overwritten on Windows — stop the service
|
|
3216
|
-
// first (task end + process kill), THEN swap with retry.
|
|
3217
|
-
// **THE RESTART IS IN A `finally` NOW, WHICH IS THE WHOLE POINT.** It used to be the LAST statement of a ~24-step
|
|
3218
|
-
// sequence — 12 copy retries, a registry block, a Remove-Item, a three-file desktop loop — with nothing guarding
|
|
3219
|
-
// it, so an unhandled error, a dead WMI process or a WMI timeout anywhere in between meant the start never ran and
|
|
3220
|
-
// the device stayed dark. Measured 2026-09-28: a device took `summrise update`, the swap answered 502, and its
|
|
3221
|
-
// tunnel went from 502 to **530 / error code 1033** (no connector at all). The task's own watchdog does not cover
|
|
3222
|
-
// it either — `RestartCount 8 -RestartInterval 1min` fires on a CRASH, and a STOPPED task is not a crash.
|
|
3223
|
-
// **`try`/`finally` WITHOUT `catch` IS DELIBERATE**: the failure still propagates to the exit code, and the restart
|
|
3224
|
-
// happens regardless. PowerShell runs a `finally` block for an uncaught exception and for `exit`.
|
|
3225
|
-
"try {",
|
|
3226
|
-
"try { Stop-ScheduledTask SummriseAgent -ErrorAction Stop } catch {}",
|
|
3227
|
-
"Get-Process summrise-agent -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue",
|
|
3228
|
-
"Start-Sleep -Milliseconds 1500",
|
|
3229
|
-
"$ok=$false",
|
|
3230
|
-
`foreach($i in 1..12){ try { Copy-Item -Force -ErrorAction Stop '${q}\\summrise-agent.new.exe' '${q}\\summrise-agent.exe'; $ok=$true; break } catch { Start-Sleep -Milliseconds 800 } }`,
|
|
3231
|
-
`"[$(Get-Date -Format o)] copy ok=$ok" | ${log}`,
|
|
3232
|
-
// round-298: .summrise-release is only written when the copy provably
|
|
3233
|
-
// completed (a failed swap keeps the device on the OLD exe — the
|
|
3234
|
-
// marker must not lie). The marker is what agent_update compares.
|
|
3235
|
-
`if ($ok -and '${relVer}') { Set-Content -Path '${q}\\etc\\.summrise-release' -Value '${relVer}' -NoNewline -ErrorAction SilentlyContinue }`,
|
|
3236
|
-
// Add/Remove-Programs parity (same $ok gate — a failed swap must not
|
|
3237
|
-
// move the displayed version either).
|
|
3238
|
-
...uninstallVersionPs(q, relVer),
|
|
3239
|
-
`Remove-Item -Force -ErrorAction SilentlyContinue '${q}\\summrise-agent.new.exe'`,
|
|
3240
|
-
// stage-l: swap the desktop shell sources (main/preload) with retry —
|
|
3241
|
-
// the running Electron may hold them briefly.
|
|
3242
|
-
`foreach($df in @('main.js','preload.js','url-policy.js')){ $ds='${q}\\components\\summrise-desktop-electron\\src\\'+$df+'.new'; if (Test-Path $ds) { $ok2=$false; foreach($i in 1..8){ try { Copy-Item -Force -ErrorAction Stop $ds ('${q}\\components\\summrise-desktop-electron\\src\\'+$df); $ok2=$true; break } catch { Start-Sleep -Milliseconds 500 } }; Remove-Item -Force -ErrorAction SilentlyContinue $ds; "[$(Get-Date -Format o)] desk $df ok=$ok2" | ${log} } }`,
|
|
3243
|
-
// NEVER leave the device dark: even a failed swap must bring the task
|
|
3244
|
-
// back up (it will run the old exe until the next update).
|
|
3245
|
-
// **AND THE LINE THAT REPORTS IT SAYS WHAT HAPPENED, BECAUSE THE OLD ONE COULD NOT BE WRONG.**
|
|
3246
|
-
// It read `"task restarted"` unconditionally, immediately after a try/catch whose BOTH arms could fail — so the log
|
|
3247
|
-
// asserted a restart it had never verified. Measured 2026-09-28: a device took `summrise update`, the swap answered
|
|
3248
|
-
// 502, and the device went dark (the tunnel went from 502 to **530/1033** — no connector at all), while this line
|
|
3249
|
-
// would have said the task was back. `$rs` is the outcome, and it is what the log carries.
|
|
3250
|
-
"} finally {",
|
|
3251
|
-
` $rs=$false; try { Start-ScheduledTask SummriseAgent -ErrorAction Stop; $rs=$true } catch { try { schtasks /Run /TN SummriseAgent; $rs=$true } catch {} }`,
|
|
3252
|
-
` "[$(Get-Date -Format o)] task restart ok=$rs" | ${log}`,
|
|
3253
|
-
"}",
|
|
3254
|
-
`try { Remove-Item -Force (${busyMarkerPs()}) } catch {}`,
|
|
3255
|
-
// Custom-port installs: the firewall rule must track the configured
|
|
3256
|
-
// bind port (baked at update time from the live config.yaml — the
|
|
3257
|
-
// swap itself runs from a static file and cannot read it).
|
|
3258
|
-
...firewallPs(agentPort(ETC_DIR)),
|
|
3259
|
-
// stage-n: restart the Electron shell so newly-synced main/preload
|
|
3260
|
-
// sources take effect. The shell is INDEPENDENT of the SummriseAgent task —
|
|
3261
|
-
// it probes the configured port and loads /desktop/. Kill + relaunch
|
|
3262
|
-
// via start-desktop.ps1 (the same path SummriseDesktop onlogon uses); if
|
|
3263
|
-
// the task/script is missing (non-desktop install), skip silently.
|
|
3264
|
-
`$deskDir = '${q}\\components\\summrise-desktop-electron'`,
|
|
3265
|
-
`$deskStart = '${q}\\scripts\\start-desktop.ps1'`,
|
|
3266
|
-
// stage-n: harden the SHELL supervisor itself — SummriseDesktop gains a
|
|
3267
|
-
// 5-minute repetition trigger so a dead electron is reborn within
|
|
3268
|
-
// ≤5 min (previously only started at logon: "the watchdog died" left
|
|
3269
|
-
// d1 dark on 2026-09-25). Two field-test lessons encoded here:
|
|
3270
|
-
// - `schtasks /Change /RI 5` prompts for the /ru password interactively
|
|
3271
|
-
// (hung the PTY) and PS-array invocation mangles cmd-style args —
|
|
3272
|
-
// use the ScheduledTasks cmdlets (SYSTEM runs them without prompt,
|
|
3273
|
-
// mirroring SummriseAgent's proven -Once + -RepetitionInterval pattern).
|
|
3274
|
-
// - the pulse must NOT start a second electron while one is alive
|
|
3275
|
-
// (second-instance focuses the window = focus steal every 5 min) —
|
|
3276
|
-
// the guarded ensure-desktop.ps1 checks Get-Process first, and the
|
|
3277
|
-
// wscript wrapper runs it with no console flash.
|
|
3278
|
-
`$en1 = '${q}\\scripts\\ensure-desktop.ps1'`,
|
|
3279
|
-
`$vb1 = '${q}\\scripts\\desktop-pulse.vbs'`,
|
|
3280
|
-
// THE SAME BUILDER `setup` USES, and this is the path that matters most: an update is
|
|
3281
|
-
// the ONLY mechanism that reaches a machine already installed, so a watchdog written
|
|
3282
|
-
// here from a second, hand-typed copy would silently overwrite the instrumented one on
|
|
3283
|
-
// every fleet device while `setup` looked correct. `$en1` is assigned above and the
|
|
3284
|
-
// here-string needs no interpolation, so the body travels verbatim.
|
|
3285
|
-
`$enBody = @'`,
|
|
3286
|
-
...ensureDesktopPs(`${q}\\scripts`, qd),
|
|
3287
|
-
`'@`,
|
|
3288
|
-
`Set-Content -Path $en1 -Value $enBody -Force`,
|
|
3289
|
-
`Set-Content -Path $vb1 -Value 'CreateObject("WScript.Shell").Run "powershell -NoProfile -ExecutionPolicy Bypass -File " & Chr(34) & "${q}\\scripts\\ensure-desktop.ps1" & Chr(34), 0, False' -Force`,
|
|
3290
|
-
`if ($null -ne (Get-ScheduledTask -TaskName 'SummriseDesktop' -ErrorAction SilentlyContinue)) {`,
|
|
3291
|
-
` $da = New-ScheduledTaskAction -Execute 'wscript.exe' -Argument ('"' + $vb1 + '"') -WorkingDirectory '${q}'`,
|
|
3292
|
-
` $dt1 = New-ScheduledTaskTrigger -AtLogOn`,
|
|
3293
|
-
` $dw1 = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -RepetitionInterval (New-TimeSpan -Minutes 5)`,
|
|
3294
|
-
// AND THE SETTINGS, WHICH IS THE HALF THAT REACHES A MACHINE ALREADY INSTALLED.
|
|
3295
|
-
// This call used to pass only -Action and -Trigger, and `Set-ScheduledTask` PRESERVES the
|
|
3296
|
-
// components it is not given — so an execution limit already on the task survived every
|
|
3297
|
-
// update. That is the wrong shape for a heal: `summrise update` is the only mechanism that
|
|
3298
|
-
// reaches the fleet, and a device carrying the old 10-minute limit would have kept it
|
|
3299
|
-
// forever while its log said "hardened". The value is the same one `setup` writes, so the
|
|
3300
|
-
// two cannot diverge — and installer_integrity.rs holds both ends to it.
|
|
3301
|
-
` $dst = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew`,
|
|
3302
|
-
` Set-ScheduledTask -TaskName 'SummriseDesktop' -Action $da -Trigger @($dt1, $dw1) -Settings $dst | Out-Null`,
|
|
3303
|
-
` "[$(Get-Date -Format o)] desk: SummriseDesktop hardened (guarded 5-min pulse)" | ${log}`,
|
|
3304
|
-
`}`,
|
|
3305
|
-
`if ((Test-Path $deskDir) -and (Test-Path $deskStart)) {`,
|
|
3306
|
-
` "[$(Get-Date -Format o)] desk: restarting electron shell" | ${log}`,
|
|
3307
|
-
` Get-Process electron -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
|
|
3308
|
-
` Start-Sleep -Milliseconds 1500`,
|
|
3309
|
-
` $deskTask = Get-ScheduledTask -TaskName 'SummriseDesktop' -ErrorAction SilentlyContinue`,
|
|
3310
|
-
// Start-ScheduledTask works for Ready AND Running tasks (Running is a
|
|
3311
|
-
// no-op) — use it unconditionally; the WMI-hosted swap process must not
|
|
3312
|
-
// spawn electron as its own child (it would be reaped with us; proven
|
|
3313
|
-
// twice on d1).
|
|
3314
|
-
` if ($deskTask) { Start-ScheduledTask -TaskName 'SummriseDesktop' -ErrorAction SilentlyContinue }`,
|
|
3315
|
-
// no SummriseDesktop task (headless install): `cmd start` detaches the
|
|
3316
|
-
// shell from the swap host so it is not reaped with it.
|
|
3317
|
-
` else { & cmd /c start /min "" powershell -NoProfile -ExecutionPolicy Bypass -File "$deskStart" }`,
|
|
3318
|
-
` "[$(Get-Date -Format o)] desk: electron restart initiated" | ${log}`,
|
|
3319
|
-
`} else { "[$(Get-Date -Format o)] desk: no electron shell (skipped)" | ${log} }`,
|
|
3320
|
-
// stage-brand: heal a stale desktop shortcut (Summrise.lnk -> retired
|
|
3321
|
-
// Tauri exe) + drop the retired orphans. Repair-only, best-effort.
|
|
3322
|
-
...deskShortcutRepairPs(`${q}\\scripts`, `${q}\\components\\summrise-desktop-electron`, log),
|
|
3323
|
-
// round-143: re-register SummrisePlaywright via the wscript/VBS wrapper so
|
|
3324
|
-
// node.exe no longer allocates a visible console. Idempotent — task may
|
|
3325
|
-
// not exist (older install paths), so wrap in try/catch.
|
|
3326
|
-
`$pwVbs = '${q}\\scripts\\run-hidden.vbs'`,
|
|
3327
|
-
`$pwProbe = '${q}\\scripts\\playwright-probe.ps1'`,
|
|
3328
|
-
`if ((Test-Path $pwVbs) -and (Test-Path $pwProbe)) {`,
|
|
3329
|
-
` $pwNode = '${q}\\components\\playwright\\node.exe'`,
|
|
3330
|
-
` $pwCli = '${q}\\components\\playwright\\node_modules\\@playwright\\mcp\\cli.js'`,
|
|
3331
|
-
` if ((Test-Path $pwNode) -and (Test-Path $pwCli)) {`, // parens: bare -and is a param parse error
|
|
3332
|
-
// Read the CURRENT task's UserId BEFORE unregistering — we need to know
|
|
3333
|
-
// who the task runs as (Administrator), but $env:USERNAME returns
|
|
3334
|
-
// "SYSTEM" when spawned via WMI, and Win32_ComputerSystem.UserName is
|
|
3335
|
-
// empty from session 0. The existing task's Principal is the most
|
|
3336
|
-
// reliable source. Fall back to the local user if the task doesn't exist.
|
|
3337
|
-
` $oldTask = Get-ScheduledTask -TaskName 'SummrisePlaywright' -ErrorAction SilentlyContinue`,
|
|
3338
|
-
` $pwUser = if ($oldTask) { $oldTask.Principal.UserId } else { (Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue).UserName -replace '^.*\\\\', '' }`,
|
|
3339
|
-
` try { Unregister-ScheduledTask -TaskName 'SummrisePlaywright' -Confirm:$false -ErrorAction SilentlyContinue } catch {}`,
|
|
3340
|
-
// round-246 (C3) + round-263: route through the probe launcher — it
|
|
3341
|
-
// attaches to the Electron desktop view (CDP 9333) when up, so AI
|
|
3342
|
-
// actions on 9229 drive the SAME browser the user watches (no more
|
|
3343
|
-
// invisible private headless).
|
|
3344
|
-
` $pwPs = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'`,
|
|
3345
|
-
` $pwArgs = '"' + $pwVbs + '" "' + $pwPs + '" -NoProfile -File "' + $pwProbe + '" "' + $pwNode + '" "' + $pwCli + '"'`,
|
|
3346
|
-
` $pwAction = New-ScheduledTaskAction -Execute (Join-Path $env:SystemRoot 'System32\\wscript.exe') -Argument $pwArgs`,
|
|
3347
|
-
` $pwBoot = New-ScheduledTaskTrigger -AtLogOn`,
|
|
3348
|
-
` $pwWatch = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 5)`,
|
|
3349
|
-
` $pwSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable`,
|
|
3350
|
-
` Register-ScheduledTask -TaskName 'SummrisePlaywright' -Action $pwAction -Trigger @($pwBoot, $pwWatch) -Principal (New-ScheduledTaskPrincipal -UserId $pwUser -LogonType Interactive -RunLevel Limited) -Settings $pwSettings -Force | Out-Null`,
|
|
3351
|
-
` Start-ScheduledTask -TaskName 'SummrisePlaywright' | Out-Null`,
|
|
3352
|
-
` "[$(Get-Date -Format o)] SummrisePlaywright re-registered (probe launcher, user=$pwUser)" | ${log}`,
|
|
3353
|
-
` }`,
|
|
3354
|
-
`}`,
|
|
3355
|
-
// Layout v2: the swap script itself is transient — delete it last
|
|
3356
|
-
// (the Rust agent_update twin already self-deletes; this one lingered
|
|
3357
|
-
// at the install root forever).
|
|
3358
|
-
`try { Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction Stop } catch {}`,
|
|
3359
|
-
].join("\r\n");
|
|
3360
|
-
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
|
|
3361
|
-
fs.writeFileSync(path.join(SCRIPTS_DIR, "summrise-update.ps1"), script);
|
|
3362
|
-
// Launch the swap via WMI Win32_Process.Create: the child is parented by
|
|
3363
|
-
// WmiPrvSE, outside any caller job, so it survives this CLI (and the
|
|
3364
|
-
// agent it kills) dying — node's detached spawn does NOT (observed d1).
|
|
3365
|
-
//
|
|
3366
|
-
// Flags matter on this path: children created via WMI with
|
|
3367
|
-
// `-ExecutionPolicy Bypass` or `-EncodedCommand` in their command line
|
|
3368
|
-
// die silently before running anything (d1, no Defender ASR events —
|
|
3369
|
-
// cause unconfirmed). Plain `powershell -NoProfile -File` works. That
|
|
3370
|
-
// requires script execution to be allowed, so lift Restricted here once;
|
|
3371
|
-
// RemoteSigned is Microsoft's recommended default for automation hosts.
|
|
3372
|
-
(0, child_process_1.spawnSync)("powershell", [
|
|
3373
|
-
"-NoProfile",
|
|
3374
|
-
"-Command",
|
|
3375
|
-
"if((Get-ExecutionPolicy) -eq 'Restricted'){ Set-ExecutionPolicy RemoteSigned -Scope LocalMachine -Force }",
|
|
3376
|
-
], { stdio: "ignore", timeout: 30000 });
|
|
3377
|
-
const ps1 = path.join(SCRIPTS_DIR, "summrise-update.ps1");
|
|
3378
|
-
// stage-n npm audit LOW: DIR can contain characters that break the
|
|
3379
|
-
// inner PS double-quote literal (backslash, quote). Escape for the
|
|
3380
|
-
// inner -File arg; the outer WMI literal is already escaped on L562.
|
|
3381
|
-
const ps1Safe = ps1.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3382
|
-
const inner = `powershell -NoProfile -File "${ps1Safe}"`;
|
|
3383
|
-
const wmi = `Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine='${inner.replace(/'/g, "''")}'} | ConvertTo-Json -Compress`;
|
|
3384
|
-
const r = (0, child_process_1.spawnSync)("powershell", ["-NoProfile", "-Command", wmi], {
|
|
3385
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3386
|
-
timeout: 20000,
|
|
3387
|
-
});
|
|
3388
|
-
// review #2 (HIGH): Win32_Process.Create reports success via
|
|
3389
|
-
// ReturnValue=0, but the old stdio:"inherit" printed the object and
|
|
3390
|
-
// checked only powershell's own exit code — a Create that returned 9
|
|
3391
|
-
// (path not found) / 21 still printed "swap launched" and the update
|
|
3392
|
-
// was a silent no-op (exactly incident #1's class). Parse the code.
|
|
3393
|
-
let retval = null;
|
|
3394
|
-
try {
|
|
3395
|
-
const j = JSON.parse((r.stdout || Buffer.from("")).toString().trim());
|
|
3396
|
-
retval = typeof j?.ReturnValue === "number" ? j.ReturnValue : null;
|
|
3397
|
-
}
|
|
3398
|
-
catch {
|
|
3399
|
-
/* fall through to the status/retval guard below */
|
|
3400
|
-
}
|
|
3401
|
-
if (r.status !== 0 || retval !== 0) {
|
|
3402
|
-
try {
|
|
3403
|
-
fs.unlinkSync(BUSYM);
|
|
3404
|
-
}
|
|
3405
|
-
catch {
|
|
3406
|
-
/* never created */
|
|
3407
|
-
}
|
|
3408
|
-
console.error(`update: WMI handoff failed (ps status ${r.status}, ReturnValue ${retval ?? "?"})` +
|
|
3409
|
-
(r.stderr ? " — " + r.stderr.toString().trim() : ""));
|
|
3410
|
-
process.exit(1);
|
|
3411
|
-
}
|
|
3412
|
-
// ASK THE DEVICE, DO NOT TRUST THE HANDOFF. `ReturnValue=0` means a process was
|
|
3413
|
-
// created; every decision that matters (the fail-closed migration gate, the 12x
|
|
3414
|
-
// copy retry, the task restart) happens after, in a WmiPrvSE-parented script whose
|
|
3415
|
-
// exit code nobody reads — so a swap that fails one second later looked identical
|
|
3416
|
-
// to one that worked. `rollback` already solves this by reading the release marker
|
|
3417
|
-
// back; `update` reported the HANDOFF instead and this is the only reason the two
|
|
3418
|
-
// commands disagreed about whether an update took.
|
|
3419
|
-
//
|
|
3420
|
-
// The read is a FILE, not the network, so the dropped connection does not matter.
|
|
3421
|
-
// A successful swap finishes in ~10s; the bound only elapses on failure, and it is
|
|
3422
|
-
// the same bound `rollback` uses.
|
|
3423
|
-
console.log("update: swap launched -- waiting for the device to confirm");
|
|
3424
|
-
const markerFile = path.join(ETC_DIR, ".summrise-release");
|
|
3425
|
-
const check = await awaitReleaseMarker({
|
|
3426
|
-
want: toVersion,
|
|
3427
|
-
timeoutMs: 90_000,
|
|
3428
|
-
intervalMs: 2_000,
|
|
3429
|
-
read: () => fs.readFileSync(markerFile, "utf8"),
|
|
3430
|
-
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
3431
|
-
now: () => Date.now(),
|
|
3432
|
-
});
|
|
3433
|
-
const verdict = releaseMarkerVerdict({
|
|
3434
|
-
...check,
|
|
3435
|
-
want: toVersion,
|
|
3436
|
-
// THE VERB MATTERS: this is an INSTALL that failed, so the advice names the previous version as
|
|
3437
|
-
// the thing to pin — not the one that just failed to install.
|
|
3438
|
-
verb: "update",
|
|
3439
|
-
from: fromVersion,
|
|
3440
|
-
});
|
|
3441
|
-
if (verdict.writePin) {
|
|
3442
|
-
console.log(`update: ${fromVersion || "?"} -> ${toVersion} COMPLETE (the device reported the new release)`);
|
|
3443
|
-
}
|
|
3444
|
-
else {
|
|
3445
|
-
// Not a pin decision here — `update` never writes one — but the same verdict
|
|
3446
|
-
// logic answers "did it take", which is the question the operator asked.
|
|
3447
|
-
console.error(verdict.message);
|
|
3448
|
-
process.exit(1);
|
|
3449
|
-
}
|
|
3450
|
-
},
|
|
3451
|
-
// `summrise rollback <x.y.z> | --clear` — pin the device to a CDN-retained
|
|
3452
|
-
// release and prevent the agent_update auto-upgrade from undoing it.
|
|
3453
|
-
// Mechanics (the swap itself reuses the installed package's `update`):
|
|
3454
|
-
// 1. HEAD-check the pinned tgz (CDN keeps last-5-per-minor — a pruned
|
|
3455
|
-
// version fails HERE with a clear message, not as an npm 404 storm).
|
|
3456
|
-
// 2. npm install -g --prefix <components\npm-global> <tgz> — replaces
|
|
3457
|
-
// the package whose bin/summrise.js + exe the swap below uses.
|
|
3458
|
-
// 3. <npm-global>\summrise.cmd update — runs the TARGET version's own swap
|
|
3459
|
-
// (its staged exe IS the rollback build). Old (pre-v2) scripts write
|
|
3460
|
-
// .summrise-release to the install ROOT; steps 4/5 heal that: sync the
|
|
3461
|
-
// marker into etc\ and delete the root leftover so agent_update can
|
|
3462
|
-
// never read a split-brain version.
|
|
3463
|
-
// 4/5. write etc\.rollback-pin = <ver>, sync etc\.summrise-release.
|
|
3464
|
-
// agent_update (Rust) refuses any non-matching version while the pin
|
|
3465
|
-
// exists; force:true (or a real Rust-side upgrade) clears it — same for
|
|
3466
|
-
// a later `summrise rollback --clear`. A human `summrise update` deliberately
|
|
3467
|
-
// does NOT clear the pin: the update flow swaps whatever npm-global
|
|
3468
|
-
// holds, so after rollback that IS the pinned version (no-op), and the
|
|
3469
|
-
// pin stays authoritative for the auto path.
|
|
3470
|
-
async rollback(args) {
|
|
3471
|
-
const NPM_GLOBAL = path.join(COMPONENTS_DIR, "npm-global");
|
|
3472
|
-
const PIN = path.join(ETC_DIR, ".rollback-pin");
|
|
3473
|
-
const val = String(args[0] || "");
|
|
3474
|
-
if (val === "--clear") {
|
|
3475
|
-
// A FAILED DELETE IS NOT AN ABSENT PIN. `rmSync(force)` ignores only ENOENT;
|
|
3476
|
-
// EPERM/EACCES/EBUSY/EISDIR landed in this same catch, so a pin that SURVIVED was
|
|
3477
|
-
// reported as "nothing to clear" and the command returned 0 — while
|
|
3478
|
-
// `rollback status` still said "pinned" and agent_update kept refusing every
|
|
3479
|
-
// release, which is the state that governs auto-updates.
|
|
3480
|
-
let cur = null;
|
|
3481
|
-
try {
|
|
3482
|
-
cur = fs.readFileSync(PIN, "utf8").trim();
|
|
3483
|
-
}
|
|
3484
|
-
catch (e) {
|
|
3485
|
-
if (e && e.code !== "ENOENT") {
|
|
3486
|
-
console.error(`rollback: could not READ ${PIN} (${e.code || e.message}) -- not assuming it is absent`);
|
|
3487
|
-
process.exitCode = 1;
|
|
3488
|
-
return;
|
|
3489
|
-
}
|
|
3490
|
-
}
|
|
3491
|
-
try {
|
|
3492
|
-
fs.rmSync(PIN, { force: true });
|
|
3493
|
-
}
|
|
3494
|
-
catch (e) {
|
|
3495
|
-
// fall through to the read-back below, which is what decides.
|
|
3496
|
-
}
|
|
3497
|
-
if (fs.existsSync(PIN)) {
|
|
3498
|
-
console.error(`rollback: FAILED to clear ${PIN} -- the pin is still in place and agent_update keeps refusing releases`);
|
|
3499
|
-
process.exitCode = 1;
|
|
3500
|
-
}
|
|
3501
|
-
else if (cur !== null) {
|
|
3502
|
-
console.log(`rollback: pin cleared (was ${cur || "?"}) -- agent_update tracks the release channel again`);
|
|
3503
|
-
}
|
|
3504
|
-
else {
|
|
3505
|
-
console.log("rollback: no pin present (nothing to clear)");
|
|
3506
|
-
}
|
|
3507
|
-
return;
|
|
3508
|
-
}
|
|
3509
|
-
if (val === "status") {
|
|
3510
|
-
try {
|
|
3511
|
-
console.log("rollback: pinned to", fs.readFileSync(PIN, "utf8").trim());
|
|
3512
|
-
}
|
|
3513
|
-
catch (e) {
|
|
3514
|
-
// "not pinned" is a claim about ABSENCE; a read error is not evidence of it.
|
|
3515
|
-
if (e && e.code !== "ENOENT") {
|
|
3516
|
-
console.error(`rollback: could not read ${PIN} (${e.code || e.message}) -- pin state UNKNOWN`);
|
|
3517
|
-
process.exitCode = 1;
|
|
3518
|
-
}
|
|
3519
|
-
else {
|
|
3520
|
-
console.log("rollback: not pinned (tracks the release channel)");
|
|
3521
|
-
}
|
|
3522
|
-
}
|
|
3523
|
-
return;
|
|
3524
|
-
}
|
|
3525
|
-
if (!rollbackVersionOk(val)) {
|
|
3526
|
-
console.error("usage: summrise rollback <x.y.z> | status | --clear");
|
|
3527
|
-
process.exit(1);
|
|
3528
|
-
}
|
|
3529
|
-
const base = (process.env.SUMMRISE_CDN || "https://agent.saisi.online").replace(/\/+$/, "");
|
|
3530
|
-
const url = `${base}/summrise-agent/summrise-agent-${val}.tgz`;
|
|
3531
|
-
const head = (0, child_process_1.spawnSync)("curl", [
|
|
3532
|
-
"-s",
|
|
3533
|
-
"-o",
|
|
3534
|
-
"/dev/null",
|
|
3535
|
-
"-w",
|
|
3536
|
-
"%{http_code}",
|
|
3537
|
-
"-m",
|
|
3538
|
-
"30",
|
|
3539
|
-
"--head",
|
|
3540
|
-
url,
|
|
3541
|
-
], { encoding: "utf8", timeout: 40000 });
|
|
3542
|
-
const code = String(head.stdout || "").trim();
|
|
3543
|
-
if (head.status !== 0 || code !== "200") {
|
|
3544
|
-
console.error(`rollback: ${val} is not on the release CDN (HTTP ${code || "?"}) -- the last-5-per-minor prune removed it;` +
|
|
3545
|
-
" pick a retained version (see https://agent.saisi.online/summrise-agent/version.json for the current line)");
|
|
3546
|
-
process.exit(1);
|
|
3547
|
-
}
|
|
3548
|
-
console.log(`rollback: installing summrise-agent ${val} into ${NPM_GLOBAL} ...`);
|
|
3549
|
-
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
3550
|
-
const inst = (0, child_process_1.spawnSync)(npmCmd, ["install", "-g", "--prefix", NPM_GLOBAL, url], { stdio: "inherit", timeout: 300000 });
|
|
3551
|
-
// `status !== 0` is the FAIL-CLOSED test here — unlike the reversed checks fixed
|
|
3552
|
-
// elsewhere in this file, a spawn failure or timeout yields `status: null`, which is
|
|
3553
|
-
// also `!== 0`, so it aborts. Only the MESSAGE conflated the two, which matters
|
|
3554
|
-
// because "npm install failed" sends an operator to look at the registry while
|
|
3555
|
-
// "npm could not be run" sends them to look at PATH.
|
|
3556
|
-
if (inst.error || inst.status !== 0) {
|
|
3557
|
-
console.error(inst.error
|
|
3558
|
-
? `rollback: could not RUN npm (${inst.error.message}) -- device left untouched`
|
|
3559
|
-
: `rollback: npm install failed (exit ${inst.status}) -- device left untouched`);
|
|
3560
|
-
process.exit(1);
|
|
3561
|
-
}
|
|
3562
|
-
const summriseCmd = path.join(NPM_GLOBAL, "summrise.cmd");
|
|
3563
|
-
if (!fs.existsSync(summriseCmd)) {
|
|
3564
|
-
console.error("rollback: summrise.cmd missing after install (broken package?) -- aborting before any swap");
|
|
3565
|
-
process.exit(1);
|
|
3566
|
-
}
|
|
3567
|
-
console.log(`rollback: swapping in ${val} (connection drops ~10s) ...`);
|
|
3568
|
-
const upd = (0, child_process_1.spawnSync)(summriseCmd, ["update"], {
|
|
3569
|
-
stdio: "inherit",
|
|
3570
|
-
timeout: 120000,
|
|
3571
|
-
});
|
|
3572
|
-
if (upd.status !== 0) {
|
|
3573
|
-
console.error("rollback: swap failed -- pin NOT written, device still runs the previous release");
|
|
3574
|
-
process.exit(1);
|
|
3575
|
-
}
|
|
3576
|
-
// status 0 means the HANDOFF was accepted, not that the swap succeeded (the
|
|
3577
|
-
// script that decides that runs afterwards, parented by WmiPrvSE, with
|
|
3578
|
-
// nobody reading its exit code). So ASK THE DEVICE: read the marker back and
|
|
3579
|
-
// require it to show the version we just staged. Only then is the pin — and
|
|
3580
|
-
// the claim to every UI — earned.
|
|
3581
|
-
const markerFile = path.join(ETC_DIR, ".summrise-release");
|
|
3582
|
-
const check = await awaitReleaseMarker({
|
|
3583
|
-
want: val,
|
|
3584
|
-
timeoutMs: 90_000,
|
|
3585
|
-
intervalMs: 2_000,
|
|
3586
|
-
read: () => fs.readFileSync(markerFile, "utf8"),
|
|
3587
|
-
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
3588
|
-
now: () => Date.now(),
|
|
3589
|
-
});
|
|
3590
|
-
const verdict = releaseMarkerVerdict({ ...check, want: val });
|
|
3591
|
-
if (!verdict.writePin) {
|
|
3592
|
-
console.error(verdict.message);
|
|
3593
|
-
process.exit(verdict.exitCode);
|
|
3594
|
-
}
|
|
3595
|
-
// TWO OPERATIONS, TWO VERDICTS. They shared one try, so a CLEANUP failure
|
|
3596
|
-
// (rmSync) printed "pin write failed ... agent_update is NOT blocked" — the exact
|
|
3597
|
-
// inverse of the truth: the pin HAD been written and agent_update WAS blocked. It
|
|
3598
|
-
// also swallowed the success line, because that was inside the same try.
|
|
3599
|
-
let pinned = false;
|
|
3600
|
-
try {
|
|
3601
|
-
fs.mkdirSync(ETC_DIR, { recursive: true });
|
|
3602
|
-
fs.writeFileSync(PIN, val);
|
|
3603
|
-
pinned = true;
|
|
3604
|
-
}
|
|
3605
|
-
catch (e) {
|
|
3606
|
-
console.error("rollback: WARNING -- pin write failed (" +
|
|
3607
|
-
e.message +
|
|
3608
|
-
"); device runs " +
|
|
3609
|
-
val +
|
|
3610
|
-
" but agent_update is NOT blocked");
|
|
3611
|
-
}
|
|
3612
|
-
if (pinned) {
|
|
3613
|
-
// Decided on a READ-BACK, like `--clear` and the marker check: the write not
|
|
3614
|
-
// throwing is not the same as the pin being in place.
|
|
3615
|
-
let back = null;
|
|
3616
|
-
try {
|
|
3617
|
-
back = fs.readFileSync(PIN, "utf8").trim();
|
|
3618
|
-
}
|
|
3619
|
-
catch {
|
|
3620
|
-
/* reported below */
|
|
3621
|
-
}
|
|
3622
|
-
if (back !== val) {
|
|
3623
|
-
console.error(`rollback: WARNING -- wrote ${PIN} but read back ${back ?? "nothing"}; agent_update may not be blocked`);
|
|
3624
|
-
}
|
|
3625
|
-
else {
|
|
3626
|
-
console.log(verdict.message);
|
|
3627
|
-
}
|
|
3628
|
-
// Heal a pre-v2 swap's split-brain marker (old CLI wrote ROOT .summrise-release;
|
|
3629
|
-
// the agent reads etc\). Root leftover is garbage. Cleanup only — it can never
|
|
3630
|
-
// change whether the pin exists, so it gets its OWN message.
|
|
3631
|
-
// NOTE: etc\.summrise-release is NOT written here — the swap script wrote it from a
|
|
3632
|
-
// provable copy, and overwriting it would erase that proof.
|
|
3633
|
-
try {
|
|
3634
|
-
fs.rmSync(path.join(DIR, ".summrise-release"), { force: true });
|
|
3635
|
-
}
|
|
3636
|
-
catch (e) {
|
|
3637
|
-
console.error(`rollback: note -- the pin is in place, but the pre-v2 root marker could not be removed (${e.message}); it is inert`);
|
|
3638
|
-
}
|
|
3639
|
-
}
|
|
3640
|
-
},
|
|
3641
|
-
// The ONLY uninstall path (NSIS installer is retired — npm CLI is the
|
|
3642
|
-
// single install/update channel). Stops the agent, removes the scheduled
|
|
3643
|
-
// tasks, deletes the program dir + registry keys. The DATA dir
|
|
3644
|
-
// (%ProgramData%\Summrise) is KEPT by default (sessions/memory survive);
|
|
3645
|
-
// pass --purge-data to delete it too.
|
|
3646
|
-
uninstall(args) {
|
|
3647
|
-
const purge = args.includes("--purge-data");
|
|
3648
|
-
// DATA_DIR, not a local recomputation: the registry-first resolver is the single
|
|
3649
|
-
// source of truth (setup already uses it), and a second copy here meant a
|
|
3650
|
-
// registry-remapped install had the WRONG directory purged — and named in
|
|
3651
|
-
// "data kept at" — while the command reported success.
|
|
3652
|
-
const DATA = DATA_DIR;
|
|
3653
|
-
// HIGH npm audit: verify DIR is actually a Summrise install dir before
|
|
3654
|
-
// recursively deleting — an attacker who controls SUMMRISE_AGENT_DIR (env
|
|
3655
|
-
// var) or the registry key could point it at D:\Windows or C:\.
|
|
3656
|
-
if (!fs.existsSync(path.join(DIR, "summrise-agent.exe")) &&
|
|
3657
|
-
!fs.existsSync(path.join(ETC_DIR, "summrise-agent.hostname")) &&
|
|
3658
|
-
!fs.existsSync(path.join(DIR, "summrise-agent.hostname"))) {
|
|
3659
|
-
console.error("uninstall: REFUSE — " +
|
|
3660
|
-
DIR +
|
|
3661
|
-
" does not look like a Summrise install dir (no summrise-agent.exe/hostname). Set SUMMRISE_AGENT_DIR to the correct path.");
|
|
3662
|
-
process.exit(1);
|
|
3663
|
-
}
|
|
3664
|
-
console.log("uninstall: stopping SummriseAgent...");
|
|
3665
|
-
sh("cmd /c schtasks /End /TN SummriseAgent 2>NUL");
|
|
3666
|
-
sh("taskkill /F /IM summrise-agent.exe 2>NUL");
|
|
3667
|
-
sh("taskkill /F /IM summrise-desktop.exe 2>NUL");
|
|
3668
|
-
// npm audit #11: electron survived uninstall (dead SPA window); the
|
|
3669
|
-
// update-hardened SummriseDesktop 5-min pulse kept firing against the deleted dir.
|
|
3670
|
-
sh("taskkill /F /IM electron.exe 2>NUL");
|
|
3671
|
-
sh("cmd /c schtasks /End /TN SummriseDesktop 2>NUL");
|
|
3672
|
-
sh("cmd /c schtasks /Delete /TN SummriseDesktop /F 2>NUL");
|
|
3673
|
-
// kill bundled playwright node + boxed cloudflared (best effort)
|
|
3674
|
-
// Match ANY node.exe whose command line mentions a summrise playwright
|
|
3675
|
-
// bundle (covers the current install dir AND legacy dirs like
|
|
3676
|
-
// D:\summrise-agent\playwright that a fresh uninstall must clear too).
|
|
3677
|
-
sh(`powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and ($_.CommandLine -like '*summrise-agent*playwright*' -or $_.CommandLine -like '*summrise-command*playwright*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"`);
|
|
3678
|
-
sh("taskkill /F /IM cloudflared.exe 2>NUL");
|
|
3679
|
-
sh("cmd /c schtasks /Delete /TN SummriseAgent /F 2>NUL");
|
|
3680
|
-
sh("cmd /c schtasks /Delete /TN SummrisePlaywright /F 2>NUL");
|
|
3681
|
-
// legacy service cleanup (best effort)
|
|
3682
|
-
sh("sc stop Cloudflared 2>NUL");
|
|
3683
|
-
sh("sc delete Cloudflared 2>NUL");
|
|
3684
|
-
sh("reg delete HKLM\\SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\Cloudflared /f 2>NUL");
|
|
3685
|
-
// program dir + registry.
|
|
3686
|
-
//
|
|
3687
|
-
// NOT `rmdir /s /q "<DIR>"`: `rmdir` is a cmd BUILTIN — there is no rmdir.exe to hand argv
|
|
3688
|
-
// to — so a PATH given to it is text that cmd re-parses, and `%NAME%` expands even inside
|
|
3689
|
-
// those quotes (see `sh()`). DIR is the install dir, i.e. operator-chosen. `ps()` runs the
|
|
3690
|
-
// same deletion (Remove-Item -Recurse -Force, the form the legacy loop below already uses
|
|
3691
|
-
// because it also copes with locked/read-only files) with the path as argv. The VERIFY below
|
|
3692
|
-
// is what decides, which is why silence on failure is acceptable here.
|
|
3693
|
-
ps(`Remove-Item -LiteralPath '${(0, exports.psq)(DIR)}' -Recurse -Force -ErrorAction SilentlyContinue`);
|
|
3694
|
-
// Legacy install dirs from retired installers (C:\summrise-agent /
|
|
3695
|
-
// D:\summrise-agent) — uninstall must leave NO residue anywhere.
|
|
3696
|
-
for (const legacy of ["C:\\summrise-agent", "D:\\summrise-agent"]) {
|
|
3697
|
-
if (legacy !== DIR && fs.existsSync(legacy)) {
|
|
3698
|
-
console.log("uninstall: removing legacy install dir", legacy);
|
|
3699
|
-
// PowerShell Remove-Item -Recurse -Force handles locked/read-only
|
|
3700
|
-
// files better than rmdir; retry once after a short wait.
|
|
3701
|
-
//
|
|
3702
|
-
// cmd-% literal: `legacy` is one of the two STRING LITERALS in the array on the `for`
|
|
3703
|
-
// line above, so this value cannot carry a `%` — the pin re-reads that array.
|
|
3704
|
-
sh(`powershell -NoProfile -Command "Remove-Item -LiteralPath '${(0, exports.psq)(legacy)}' -Recurse -Force -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 500; Remove-Item -LiteralPath '${(0, exports.psq)(legacy)}' -Recurse -Force -ErrorAction SilentlyContinue"`);
|
|
3705
|
-
if (fs.existsSync(legacy)) {
|
|
3706
|
-
console.log("uninstall: WARNING -- legacy dir still present:", legacy);
|
|
3707
|
-
}
|
|
3708
|
-
}
|
|
3709
|
-
}
|
|
3710
|
-
sh("reg delete HKLM\\SOFTWARE\\Summrise\\Agent /f 2>NUL");
|
|
3711
|
-
// VERIFY, because `sh()` discards its result at every one of its call sites — so a
|
|
3712
|
-
// locked file, an AV hold or a denied HKLM write produced "removed" with the thing
|
|
3713
|
-
// still there, and exit 0. The legacy-dir loop just above is the pattern.
|
|
3714
|
-
const survivors = [];
|
|
3715
|
-
if (fs.existsSync(DIR))
|
|
3716
|
-
survivors.push(`install dir ${DIR}`);
|
|
3717
|
-
const regLeft = (0, child_process_1.spawnSync)("reg", ["query", "HKLM\\SOFTWARE\\Summrise\\Agent"], {
|
|
3718
|
-
encoding: "utf8",
|
|
3719
|
-
});
|
|
3720
|
-
if (regLeft.status === 0)
|
|
3721
|
-
survivors.push("registry key HKLM\\SOFTWARE\\Summrise\\Agent");
|
|
3722
|
-
if (survivors.length) {
|
|
3723
|
-
// ONE FAILURE CLASS, ONE TREATMENT. The data-dir twin forty lines below exits 1 for exactly this
|
|
3724
|
-
// ("FAILED to purge the data dir -- … is still present"), and this branch warned and exited 0 —
|
|
3725
|
-
// while the comment directly above states the rule it was breaking: `sh()` discards its result
|
|
3726
|
-
// "at every one of its call sites", so a locked file, an AV hold or a denied HKLM write "produced
|
|
3727
|
-
// 'removed' with the thing still there, AND EXIT 0".
|
|
3728
|
-
//
|
|
3729
|
-
// The program dir and the registry key are what `uninstall` was asked to remove. A warning that
|
|
3730
|
-
// exits 0 is the same false verdict the data-dir branch already refuses.
|
|
3731
|
-
console.error("uninstall: FAILED -- still present after removal:", survivors.join(", "));
|
|
3732
|
-
console.error("uninstall: a locked file or a permission problem; re-run after stopping the agent.");
|
|
3733
|
-
process.exitCode = 1;
|
|
3734
|
-
}
|
|
3735
|
-
else {
|
|
3736
|
-
console.log("uninstall: program dir + registry removed");
|
|
3737
|
-
}
|
|
3738
|
-
if (purge) {
|
|
3739
|
-
// Same as the program dir above: `rmdir` is a builtin, so its path would be text cmd
|
|
3740
|
-
// re-parses (`%NAME%` expands inside quotes), and DATA is a path the operator can remap.
|
|
3741
|
-
ps(`Remove-Item -LiteralPath '${(0, exports.psq)(DATA)}' -Recurse -Force -ErrorAction SilentlyContinue`);
|
|
3742
|
-
// A failed removal used to print "data dir purged" anyway.
|
|
3743
|
-
if (fs.existsSync(DATA)) {
|
|
3744
|
-
console.error(`uninstall: FAILED to purge the data dir -- ${DATA} is still present`);
|
|
3745
|
-
process.exitCode = 1;
|
|
3746
|
-
}
|
|
3747
|
-
else {
|
|
3748
|
-
console.log("uninstall: data dir purged");
|
|
3749
|
-
}
|
|
3750
|
-
}
|
|
3751
|
-
else {
|
|
3752
|
-
console.log("uninstall: data kept at", DATA, "(pass --purge-data to delete)");
|
|
3753
|
-
}
|
|
3754
|
-
},
|
|
3755
|
-
run(args) {
|
|
3756
|
-
// EXE_DST is always truthy, so the old `EXE_DST || EXE_SRC` was dead code and there
|
|
3757
|
-
// was no existence check: ENOENT yields `status: null`, and `?? 0` reported that as
|
|
3758
|
-
// a clean run. The banner printed either way.
|
|
3759
|
-
if (!fs.existsSync(EXE_DST)) {
|
|
3760
|
-
console.error(`summrise run: agent binary not found at ${EXE_DST} -- run 'summrise setup' first`);
|
|
3761
|
-
process.exit(1);
|
|
3762
|
-
}
|
|
3763
|
-
console.log("running summrise-agent (foreground, Ctrl+C to stop)");
|
|
3764
|
-
const r = (0, child_process_1.spawnSync)(EXE_DST, args.length ? args : [], {
|
|
3765
|
-
stdio: "inherit",
|
|
3766
|
-
});
|
|
3767
|
-
if (r.error || r.status === null) {
|
|
3768
|
-
console.error(`summrise run: could not start the agent (${r.error ? r.error.message : "no exit status"})`);
|
|
3769
|
-
process.exit(1);
|
|
3770
|
-
}
|
|
3771
|
-
process.exitCode = r.status;
|
|
3772
|
-
},
|
|
3773
|
-
// C2: cloudflared is a boxed, Summrise-supervised component — operators never
|
|
3774
|
-
// touch the binary directly. This CLI is the only handle.
|
|
3775
|
-
async tunnel(args) {
|
|
3776
|
-
const sub = args[0] || "status";
|
|
3777
|
-
const cf = path.join(COMPONENTS_DIR, "cloudflared.exe");
|
|
3778
|
-
const cfg = path.join(ETC_DIR, "tunnel.yml");
|
|
3779
|
-
const has = fs.existsSync(cf) && fs.existsSync(cfg);
|
|
3780
|
-
switch (sub) {
|
|
3781
|
-
case "status": {
|
|
3782
|
-
if (!has) {
|
|
3783
|
-
console.log("tunnel: not installed (cloudflared is OPTIONAL -- local mode needs no tunnel)");
|
|
3784
|
-
console.log(" to enable public access: `summrise tunnel install`");
|
|
3785
|
-
return;
|
|
3786
|
-
}
|
|
3787
|
-
// No shell:true — see status(): an unquoted filter through cmd.exe is
|
|
3788
|
-
// split at its spaces, so this reported STOPPED while cloudflared ran.
|
|
3789
|
-
const cfState = processRunning("cloudflared.exe");
|
|
3790
|
-
console.log(cfState === "unknown"
|
|
3791
|
-
? "tunnel: state UNKNOWN -- could not list processes (tasklist failed); do not assume it is down"
|
|
3792
|
-
: cfState === "yes"
|
|
3793
|
-
? "tunnel: RUNNING"
|
|
3794
|
-
: "tunnel: STOPPED");
|
|
3795
|
-
console.log(" binary:", cf);
|
|
3796
|
-
console.log(" config:", cfg);
|
|
3797
|
-
return;
|
|
3798
|
-
}
|
|
3799
|
-
case "install": {
|
|
3800
|
-
// THE tunnel enablement path — shared bootstrap with setup --tunnel.
|
|
3801
|
-
// A --reg-key <key> arg enables the automatic token exchange too.
|
|
3802
|
-
const ki = args.indexOf("--reg-key");
|
|
3803
|
-
const k = ki >= 0 ? args[ki + 1] : "";
|
|
3804
|
-
initTunnel(args[1] && !args[1].startsWith("--") ? args[1] : "", k);
|
|
3805
|
-
return;
|
|
3806
|
-
}
|
|
3807
|
-
case "start": {
|
|
3808
|
-
if (!has) {
|
|
3809
|
-
console.error("tunnel: not installed -- run setup with public-access enabled");
|
|
3810
|
-
process.exit(1);
|
|
3811
|
-
}
|
|
3812
|
-
// npm audit #12: detached/unref are NO-OPS on spawnSync —
|
|
3813
|
-
// `summrise tunnel start` blocked the CLI until the tunnel died.
|
|
3814
|
-
// intent to background the tunnel); kept for parity.
|
|
3815
|
-
// OBSERVE THE SPAWN. stdio was ignored, there was no 'error'/'exit' listener and
|
|
3816
|
-
// the CLI exited 0 immediately — so the success line printed for a cloudflared
|
|
3817
|
-
// that died on a bad config, a missing credentials file, a gone route, or an
|
|
3818
|
-
// already-running instance. Worse, when the SPAWN ITSELF failed there was no
|
|
3819
|
-
// 'error' listener either, so node printed the success line and THEN died with an
|
|
3820
|
-
// unhandled 'error' stack trace.
|
|
3821
|
-
let spawnErr = null;
|
|
3822
|
-
let exitedEarly = null;
|
|
3823
|
-
const ch = (0, child_process_1.spawn)(cf, ["tunnel", "--config", cfg, "run"], {
|
|
3824
|
-
stdio: "ignore",
|
|
3825
|
-
detached: true,
|
|
3826
|
-
});
|
|
3827
|
-
ch.on("error", (e) => {
|
|
3828
|
-
spawnErr = e;
|
|
3829
|
-
});
|
|
3830
|
-
ch.on("exit", (code) => {
|
|
3831
|
-
exitedEarly = code === null ? -1 : code;
|
|
3832
|
-
});
|
|
3833
|
-
ch.unref();
|
|
3834
|
-
// Give it long enough to fail visibly, then ASK the same question `tunnel
|
|
3835
|
-
// status` asks rather than assuming. `await` here is why this method is async.
|
|
3836
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
3837
|
-
if (spawnErr) {
|
|
3838
|
-
console.error(`tunnel: FAILED to start cloudflared (${spawnErr.message})`);
|
|
3839
|
-
process.exit(1);
|
|
3840
|
-
}
|
|
3841
|
-
if (exitedEarly !== null) {
|
|
3842
|
-
console.error(`tunnel: cloudflared exited immediately (code ${exitedEarly}) -- check the config and credentials`);
|
|
3843
|
-
process.exit(1);
|
|
3844
|
-
}
|
|
3845
|
-
const started = processRunning("cloudflared.exe");
|
|
3846
|
-
if (started !== "yes") {
|
|
3847
|
-
console.error("tunnel: cloudflared did not come up" +
|
|
3848
|
-
(started === "unknown"
|
|
3849
|
-
? " (and the process list could not be read, so this is not a verdict)"
|
|
3850
|
-
: "") +
|
|
3851
|
-
" -- see the tunnel log; the agent still auto-spawns it on boot");
|
|
3852
|
-
process.exit(1);
|
|
3853
|
-
}
|
|
3854
|
-
console.log("tunnel: started in background (agent also auto-spawns it on boot)");
|
|
3855
|
-
return;
|
|
3856
|
-
}
|
|
3857
|
-
case "stop": {
|
|
3858
|
-
const r = (0, child_process_1.spawnSync)("taskkill", ["/F", "/IM", "cloudflared.exe"], {
|
|
3859
|
-
stdio: "inherit",
|
|
3860
|
-
});
|
|
3861
|
-
if (r.status !== 0)
|
|
3862
|
-
console.log("tunnel: nothing to stop");
|
|
3863
|
-
return;
|
|
3864
|
-
}
|
|
3865
|
-
case "update": {
|
|
3866
|
-
console.log("tunnel: version is locked by the Summrise release flow -- update via the installer/npm package.");
|
|
3867
|
-
return;
|
|
3868
|
-
}
|
|
3869
|
-
default:
|
|
3870
|
-
console.log("usage: summrise tunnel <status|install|start|stop|update>");
|
|
3871
|
-
process.exit(1);
|
|
3872
|
-
}
|
|
3873
|
-
},
|
|
3874
|
-
};
|
|
3875
|
-
// require.main guard: test/cli.test.mjs imports the pure helpers above
|
|
3876
|
-
// WITHOUT tripping the usage print + process.exit at module load.
|
|
3877
|
-
if (require.main === module) {
|
|
3878
|
-
const [cmd, ...rest] = process.argv.slice(2);
|
|
3879
|
-
// `--version` ANSWERS THE CHECK THE INSTALLER'S OWN CHECKLIST MAKES, and it used to fail it. It is not
|
|
3880
|
-
// a verb, so it fell through to the usage branch below, printed the verb list and exited 1 — while
|
|
3881
|
-
// step 4 of `deploy/README-installer.md` BEGINS with "`summrise --version` / the panel opens". A fresh
|
|
3882
|
-
// install that worked therefore reported a failure in the one place the operator is told to look,
|
|
3883
|
-
// which is the same defect as a comment promising more than the code does.
|
|
3884
|
-
if (cmd === "--version" || cmd === "-v" || cmd === "version") {
|
|
3885
|
-
console.log(String(require("../package.json").version || ""));
|
|
3886
|
-
process.exit(0);
|
|
3887
|
-
}
|
|
3888
|
-
if (!cmd || !commands[cmd]) {
|
|
3889
|
-
// The list is DERIVED from `commands`, so the help cannot promise a verb that was pruned
|
|
3890
|
-
// (`report` and `watch` were still advertised after their round-25 removal — a usage line is a
|
|
3891
|
-
// contract, and one that lies is worse than none).
|
|
3892
|
-
console.log("summrise <" +
|
|
3893
|
-
Object.keys(commands).join("|") +
|
|
3894
|
-
"> -- Summrise Agent control");
|
|
3895
|
-
Object.keys(commands).forEach((k) => console.log(" ", k));
|
|
3896
|
-
process.exit(cmd ? 1 : 0);
|
|
3897
|
-
}
|
|
3898
|
-
// `rollback` is the only ASYNC command (it awaits the marker read-back), so
|
|
3899
|
-
// the dispatcher must catch a rejected promise: an unhandled rejection is a
|
|
3900
|
-
// raw stack trace with a non-obvious exit code, and this command runs on a
|
|
3901
|
-
// device where the operator sees only the console.
|
|
3902
|
-
Promise.resolve(commands[cmd](rest)).catch((e) => {
|
|
3903
|
-
console.error(`summrise ${cmd}: ${e && e.message ? e.message : e}`);
|
|
3904
|
-
process.exit(1);
|
|
3905
|
-
});
|
|
3906
|
-
}
|