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.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
- }