@indigoai-us/hq-cli 5.251.0 → 5.252.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.252.0] — 2026-09-27
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- `hq report publish` takes a snapshot of the finished page and publishes it as the report's preview image, using a browser already installed on the computer. A page that names its own preview image keeps it. If no browser is found the report publishes without a preview.
|
|
9
|
+
|
|
5
10
|
## [5.251.0] — 2026-09-27
|
|
6
11
|
|
|
7
12
|
## [5.250.0] — 2026-09-27
|
package/dist/commands/report.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** `hq report` — safe channel-report lifecycle commands for bot runbooks. */
|
|
2
|
+
import { snapshotReport } from "../lib/report-snapshot.js";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
@@ -339,6 +340,7 @@ async function publishWithOrg(token, report, directory, draft) {
|
|
|
339
340
|
return deployReportStatic(token, { ...input, org });
|
|
340
341
|
},
|
|
341
342
|
now: () => new Date().toISOString(),
|
|
343
|
+
snapshot: (source) => snapshotReport(source),
|
|
342
344
|
});
|
|
343
345
|
rememberReportChannelAfterSuccess(published);
|
|
344
346
|
return published;
|
|
@@ -91,7 +91,17 @@ export interface ReportPublishDependencies {
|
|
|
91
91
|
to: string;
|
|
92
92
|
body: string;
|
|
93
93
|
}): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* PNG of the finished page for the gallery card, or null when none can be
|
|
96
|
+
* made. It is given a staged copy of the files as they will be published. Only asked for when the page does not name its own preview image.
|
|
97
|
+
*/
|
|
98
|
+
snapshot?(directory: string): Promise<Buffer | null>;
|
|
94
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Add a snapshot of the page as its preview when it has none of its own.
|
|
102
|
+
* A snapshot that fails never fails the publish.
|
|
103
|
+
*/
|
|
104
|
+
export declare function withPreviewSnapshot(source: ReportSource, snapshot: ReportPublishDependencies["snapshot"]): Promise<ReportSource>;
|
|
95
105
|
/**
|
|
96
106
|
* Plain owner DM after a failed run. Suppressed for the publish lock
|
|
97
107
|
* ("already running") so a second run does not page the owner.
|
|
@@ -10,12 +10,55 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { gzipSync } from "node:zlib";
|
|
13
|
+
import { REPORT_PREVIEW_FILE } from "./report-snapshot.js";
|
|
13
14
|
import { loadUpdateReportRunbook } from "./report-templates.js";
|
|
14
15
|
/** Mirrors the deploy skill's static-report guardrails. */
|
|
15
16
|
export const REPORT_SOURCE_MAX_BYTES = 25 * 1024 * 1024;
|
|
16
17
|
export const REPORT_BUNDLE_MAX_BYTES = 10 * 1024 * 1024;
|
|
17
18
|
export const REPORT_SOURCE_MAX_FILES = 100;
|
|
18
19
|
export const REPORT_CSP = "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; form-action 'none'";
|
|
20
|
+
/**
|
|
21
|
+
* Write the files exactly as they will be published, protected index.html
|
|
22
|
+
* included, so the snapshot renders under the same rules as the live page
|
|
23
|
+
* and never opens the bot's raw page.
|
|
24
|
+
*/
|
|
25
|
+
function stageForSnapshot(files) {
|
|
26
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "hq-report-stage-"));
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const target = path.resolve(stage, file.path);
|
|
29
|
+
if (target !== stage && !target.startsWith(stage + path.sep))
|
|
30
|
+
continue;
|
|
31
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
32
|
+
fs.writeFileSync(target, Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8"));
|
|
33
|
+
}
|
|
34
|
+
return stage;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Add a snapshot of the page as its preview when it has none of its own.
|
|
38
|
+
* A snapshot that fails never fails the publish.
|
|
39
|
+
*/
|
|
40
|
+
export async function withPreviewSnapshot(source, snapshot) {
|
|
41
|
+
if (source.previewImage || !snapshot)
|
|
42
|
+
return source;
|
|
43
|
+
let image = null;
|
|
44
|
+
let stage = null;
|
|
45
|
+
try {
|
|
46
|
+
stage = stageForSnapshot(source.files);
|
|
47
|
+
image = await snapshot(stage);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
image = null;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (stage)
|
|
54
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
if (!image || image.byteLength === 0)
|
|
57
|
+
return source;
|
|
58
|
+
const files = source.files.filter((file) => file.path !== REPORT_PREVIEW_FILE);
|
|
59
|
+
files.push({ path: REPORT_PREVIEW_FILE, content: image.toString("base64"), encoding: "base64" });
|
|
60
|
+
return { ...source, files, previewImage: REPORT_PREVIEW_FILE };
|
|
61
|
+
}
|
|
19
62
|
/**
|
|
20
63
|
* Plain owner DM after a failed run. Suppressed for the publish lock
|
|
21
64
|
* ("already running") so a second run does not page the owner.
|
|
@@ -277,7 +320,7 @@ export async function publishReport(report, directory, draft, deps) {
|
|
|
277
320
|
throw error;
|
|
278
321
|
throw new ReportPublishError("Could not take the report run lock.");
|
|
279
322
|
}
|
|
280
|
-
const source = loadReportSource(directory);
|
|
323
|
+
const source = await withPreviewSnapshot(loadReportSource(directory), deps.snapshot);
|
|
281
324
|
const deployment = await deps.deploy({
|
|
282
325
|
slug: reportDeploySlug(current, draft),
|
|
283
326
|
files: source.files,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** File name of the snapshot inside the published report. */
|
|
2
|
+
export declare const REPORT_PREVIEW_FILE = "hq-preview.png";
|
|
3
|
+
export declare const REPORT_PREVIEW_WIDTH = 1280;
|
|
4
|
+
export declare const REPORT_PREVIEW_HEIGHT = 720;
|
|
5
|
+
/** First installed browser that can take a snapshot, or null. */
|
|
6
|
+
export declare function findSnapshotBrowser(env?: NodeJS.ProcessEnv, platform?: string, exists?: (file: string) => boolean): string | null;
|
|
7
|
+
export declare function snapshotArgs(indexFile: string, outFile: string, profileDir: string): string[];
|
|
8
|
+
export type SnapshotRunner = (browser: string, args: string[], outFile: string, timeoutMs: number) => Promise<void>;
|
|
9
|
+
/** PNG of the report's first screen, or null when one cannot be made. */
|
|
10
|
+
export declare function snapshotReport(directory: string, options?: {
|
|
11
|
+
browser?: string | null;
|
|
12
|
+
run?: SnapshotRunner;
|
|
13
|
+
}): Promise<Buffer | null>;
|
|
14
|
+
//# sourceMappingURL=report-snapshot.d.ts.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report preview snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Renders a report's index.html in a browser already installed on this
|
|
5
|
+
* computer and returns a PNG of the first screen. The snapshot is a nicety:
|
|
6
|
+
* every failure (no browser, timeout, empty file) returns null and the
|
|
7
|
+
* publish carries on without a preview.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
/** File name of the snapshot inside the published report. */
|
|
15
|
+
export const REPORT_PREVIEW_FILE = "hq-preview.png";
|
|
16
|
+
export const REPORT_PREVIEW_WIDTH = 1280;
|
|
17
|
+
export const REPORT_PREVIEW_HEIGHT = 720;
|
|
18
|
+
const SNAPSHOT_TIMEOUT_MS = 30_000;
|
|
19
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
20
|
+
const BROWSER_PATHS = {
|
|
21
|
+
darwin: [
|
|
22
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
23
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
24
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
25
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
26
|
+
],
|
|
27
|
+
linux: [
|
|
28
|
+
"/usr/bin/google-chrome",
|
|
29
|
+
"/usr/bin/google-chrome-stable",
|
|
30
|
+
"/usr/bin/chromium",
|
|
31
|
+
"/usr/bin/chromium-browser",
|
|
32
|
+
"/snap/bin/chromium",
|
|
33
|
+
],
|
|
34
|
+
win32: [
|
|
35
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
36
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
37
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
/** First installed browser that can take a snapshot, or null. */
|
|
41
|
+
export function findSnapshotBrowser(env = process.env, platform = process.platform, exists = fs.existsSync) {
|
|
42
|
+
const override = (env.HQ_REPORT_BROWSER ?? "").trim();
|
|
43
|
+
if (override)
|
|
44
|
+
return exists(override) ? override : null;
|
|
45
|
+
return (BROWSER_PATHS[platform] ?? []).find((file) => exists(file)) ?? null;
|
|
46
|
+
}
|
|
47
|
+
export function snapshotArgs(indexFile, outFile, profileDir) {
|
|
48
|
+
return [
|
|
49
|
+
"--headless=new",
|
|
50
|
+
"--disable-gpu",
|
|
51
|
+
"--hide-scrollbars",
|
|
52
|
+
"--no-first-run",
|
|
53
|
+
"--no-default-browser-check",
|
|
54
|
+
"--disable-extensions",
|
|
55
|
+
`--user-data-dir=${profileDir}`,
|
|
56
|
+
`--window-size=${REPORT_PREVIEW_WIDTH},${REPORT_PREVIEW_HEIGHT}`,
|
|
57
|
+
"--virtual-time-budget=5000",
|
|
58
|
+
`--screenshot=${outFile}`,
|
|
59
|
+
pathToFileURL(indexFile).toString(),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
function stop(child) {
|
|
63
|
+
if (child.pid === undefined)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
// The browser is its own process group, so its helpers stop with it.
|
|
67
|
+
if (process.platform === "win32")
|
|
68
|
+
child.kill("SIGKILL");
|
|
69
|
+
else
|
|
70
|
+
process.kill(-child.pid, "SIGKILL");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Already gone.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Some browser versions write the snapshot and then never exit. The run is
|
|
78
|
+
* done when the file is complete, so the browser is stopped at that point
|
|
79
|
+
* instead of waited on.
|
|
80
|
+
*/
|
|
81
|
+
const runBrowser = (browser, args, outFile, timeoutMs) => new Promise((resolve, reject) => {
|
|
82
|
+
const child = spawn(browser, args, { stdio: "ignore", detached: process.platform !== "win32" });
|
|
83
|
+
let lastSize = -1;
|
|
84
|
+
let settled = false;
|
|
85
|
+
const finish = (error) => {
|
|
86
|
+
if (settled)
|
|
87
|
+
return;
|
|
88
|
+
settled = true;
|
|
89
|
+
clearInterval(poll);
|
|
90
|
+
clearTimeout(limit);
|
|
91
|
+
stop(child);
|
|
92
|
+
if (error)
|
|
93
|
+
reject(error);
|
|
94
|
+
else
|
|
95
|
+
resolve();
|
|
96
|
+
};
|
|
97
|
+
const poll = setInterval(() => {
|
|
98
|
+
let size = 0;
|
|
99
|
+
try {
|
|
100
|
+
size = fs.statSync(outFile).size;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (size > 0 && size === lastSize)
|
|
106
|
+
finish();
|
|
107
|
+
lastSize = size;
|
|
108
|
+
}, 250);
|
|
109
|
+
const limit = setTimeout(() => finish(new Error("The snapshot timed out.")), timeoutMs);
|
|
110
|
+
child.once("error", (error) => finish(error));
|
|
111
|
+
child.once("exit", () => finish(fs.existsSync(outFile) ? undefined : new Error("The browser wrote no snapshot.")));
|
|
112
|
+
});
|
|
113
|
+
/** PNG of the report's first screen, or null when one cannot be made. */
|
|
114
|
+
export async function snapshotReport(directory, options = {}) {
|
|
115
|
+
const browser = options.browser === undefined ? findSnapshotBrowser() : options.browser;
|
|
116
|
+
if (!browser)
|
|
117
|
+
return null;
|
|
118
|
+
const indexFile = path.join(path.resolve(directory), "index.html");
|
|
119
|
+
if (!fs.existsSync(indexFile))
|
|
120
|
+
return null;
|
|
121
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "hq-report-preview-"));
|
|
122
|
+
try {
|
|
123
|
+
const outFile = path.join(work, REPORT_PREVIEW_FILE);
|
|
124
|
+
await (options.run ?? runBrowser)(browser, snapshotArgs(indexFile, outFile, path.join(work, "profile")), outFile, SNAPSHOT_TIMEOUT_MS);
|
|
125
|
+
const bytes = fs.readFileSync(outFile);
|
|
126
|
+
return bytes.subarray(0, 4).equals(PNG_MAGIC) ? bytes : null;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=report-snapshot.js.map
|