Skip to content

Commit fac3cc2

Browse files
gershamclaude
andcommitted
Release bundle: carry the help guidance gzip-compressed, like the assets
The plugin bundles dist/bin.js under a 1,572,864-byte per-file budget. With the BI, sources, media sync, migrate, live view and notifications commands the bundle reached 1,621,164 bytes. The help-only examples and notes in src/cli-commands/guidance.ts are about 170 KB of minified strings, so the bundler now embeds them as gzip-compressed JSON (as it already does for assets/) and inflates them once at startup: bin.js is 1,518,594 bytes. guidance.ts now holds only the two data maps (GUIDANCE_EXAMPLES, GUIDANCE_NOTES); their registration moved to register-guidance.ts unchanged. bundle-release.mjs refuses a guidance module whose exports are not plain JSON data, and guidance.test.ts checks the same in the test run. Checked: help --json for all 323 command paths is byte-identical from the unbundled dist and from the bundle, before and after; startup is unchanged (help screen 74 ms vs 75 ms). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1b8bf20 commit fac3cc2

5 files changed

Lines changed: 62 additions & 24 deletions

File tree

‎scripts/bundle-release.mjs‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
import { readdir, readFile, writeFile } from "node:fs/promises";
77
import path from "node:path";
88
import { gzipSync } from "node:zlib";
9-
import { fileURLToPath } from "node:url";
9+
import { fileURLToPath, pathToFileURL } from "node:url";
10+
import { isDeepStrictEqual } from "node:util";
1011
import { build } from "esbuild";
1112
import { isRendererPlatformPackage, loadRuntimeDependencyLock, NOTICES_FILE, RUNTIME_LOCK_FILE } from "./runtime-dependencies.mjs";
1213

@@ -37,6 +38,26 @@ const embedAssets = {
3738
},
3839
};
3940

41+
// The help-only guidance (src/cli-commands/guidance.ts) is plain JSON data and a
42+
// tenth of the bundle as minified strings, so it travels gzip-compressed too and
43+
// is inflated once at startup.
44+
const embedGuidance = {
45+
name: "embed-guidance",
46+
setup(build) {
47+
build.onLoad({ filter: /[\\/]dist[\\/]cli-commands[\\/]guidance\.js$/ }, async (args) => {
48+
const exported = { ...(await import(pathToFileURL(args.path).href)) };
49+
const json = JSON.stringify(exported);
50+
if (!isDeepStrictEqual(JSON.parse(json), exported)) throw new Error(`${args.path} must export plain JSON data only`);
51+
const packed = gzipSync(Buffer.from(json, "utf8"), { level: 9 }).toString("base64");
52+
return {
53+
loader: "js",
54+
contents: `import { gunzipSync } from "node:zlib";\nconst guidance = JSON.parse(gunzipSync(Buffer.from(${JSON.stringify(packed)}, "base64")).toString("utf8"));\n`
55+
+ Object.keys(exported).map((name) => `export const ${name} = guidance[${JSON.stringify(name)}];\n`).join(""),
56+
};
57+
});
58+
},
59+
};
60+
4061
function packageOf(input) {
4162
const parts = input.split(/[\\/]/);
4263
const marker = parts.lastIndexOf("node_modules");
@@ -73,7 +94,7 @@ async function main() {
7394
// The renderer's own loader requires its native binary by path at run time.
7495
banner: { js: 'import { createRequire as screenrigCreateRequire } from "node:module";\nconst require = screenrigCreateRequire(import.meta.url);' },
7596
external: ["@napi-rs/canvas-*"],
76-
plugins: [embedAssets],
97+
plugins: [embedAssets, embedGuidance],
7798
});
7899

79100
const bundled = [...new Set(Object.keys(result.metafile.inputs).map(packageOf).filter(Boolean))].sort();

‎src/cli-commands/guidance.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { isDeepStrictEqual } from "node:util";
4+
import * as guidance from "./guidance.js";
5+
6+
test("guidance exports plain JSON data only, so the release bundle can carry it compressed", () => {
7+
const exported = { ...guidance };
8+
assert.deepEqual(Object.keys(exported).sort(), ["GUIDANCE_EXAMPLES", "GUIDANCE_NOTES"]);
9+
assert.ok(isDeepStrictEqual(JSON.parse(JSON.stringify(exported)), exported));
10+
});

‎src/cli-commands/guidance.ts‎

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import type { Command } from "commander";
2-
import { findCommand } from "../command-path.js";
3-
import { addCommandExamples, addCommandNotes, commandExamples } from "./notes.js";
41
import { PLAYLIST_TRANSITION_HELP } from "../help-text.js";
52

63
const DOCUMENT_LINK_HELP = " An iframe page whose src is a Google Slides, Docs or Sheets share, Publish to web or embed link, or a Microsoft 365 Embed link (File → Share → Embed in Word, Excel or PowerPoint on OneDrive or SharePoint), is stored rebuilt for the screen: Slides and PowerPoint decks loop, a Slides deck advances on the link's own delay held to the page duration (give the page the time of the whole deck), and Docs, Sheets, Word and Excel reload every 5 minutes. A file that asks viewers to sign in is refused with document_not_shared, whose detail names the sharing setting to change.";
74

8-
/** Help-only workflow guidance. Syntax and validation belong to the native commands. */
9-
export function registerGuidance(root: Command): void {
10-
const examples: Record<string, string[]> = {
5+
/**
6+
* Help-only workflow guidance, keyed by command path: plain JSON data and nothing
7+
* else, so the release bundle can carry it compressed (scripts/bundle-release.mjs).
8+
* Syntax and validation belong to the native commands; register-guidance.ts attaches it.
9+
*/
10+
export const GUIDANCE_EXAMPLES: Record<string, string[]> = {
1111
"bi connection create grafana": ["--url https://grafana.example.com --token-file token.txt", '--url https://example.com/grafana --token-file - --name "Ops Grafana" --org-id 2'],
1212
"bi connection create powerbi": ['--tenant-id 11111111-2222-3333-4444-555555555555 --client-id aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --secret-file secret.txt', '--tenant-id contoso.onmicrosoft.com --client-id aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --secret-file - --name "Contoso Power BI"'],
1313
"bi connection create tableau": [
@@ -303,13 +303,8 @@ export function registerGuidance(root: Command): void {
303303
"migrate scan": ["--from files --files ./signage-media", "--from files --files ./signage-media.zip --dir ./old-signage --timezone America/Toronto"],
304304
"migrate apply": ["--dry-run", "--dir ./screenrig-migration-files", "--only media,playlists --concurrency 2"],
305305
"migrate pair": ['ABC 234 --screen "Front desk"', "ABC234 --screen 3 --dir ./old-signage"],
306-
};
307-
for (const [path, suffixes] of Object.entries(examples)) {
308-
const command = findCommand(root, path.split(" "));
309-
if (!command) throw new Error(`Unknown example command ${path}`);
310-
if (!commandExamples(command).length) addCommandExamples(command, ...suffixes.map(suffix => `screenrig ${path}${suffix ? ` ${suffix}` : ""}`));
311-
}
312-
const notes: Record<string, string> = {
306+
};
307+
export const GUIDANCE_NOTES: Record<string, string> = {
313308
"project token": "Replaces the current token subset with 1–64 consented projects. Discovery with project list does not grant access. A lost response keeps the rotation request ID for an identical retry.",
314309
"bi connection create grafana": "Adds a Grafana connection and prints it with data.secret {set, hint}; the token is sealed on the server and never shown again. Read it from --token-file FILE (the file holds only the token) or - for stdin, never an argument. In Grafana: install the Grafana Image Renderer plugin or the remote rendering service (it draws dashboards as images; without it nothing renders), then create a service account with the Viewer role and add a token. --url is Grafana's own address, including https and any path prefix (https://example.com/grafana); the server calls it from the public Internet, so a private, loopback or reserved address is invalid_request (exit 8), and Grafana must answer without redirecting. Run bi connection test next. Connections belong to one project and are not shared. Creating needs Manage access.",
315310
"bi connection create tableau": "Adds a Tableau connection: a connected app (direct trust) of your Tableau Server or Tableau Cloud site, and the Tableau user the screens' views are shown as. The app's secret value is sealed on the server and never shown again; data.secret is {set, hint}. Read it from --secret-file FILE (the file holds only the secret VALUE, not the secret ID) or - for stdin, never an argument. Setup, once: (1) in Tableau, Settings > Connected Apps > New Connected App > Direct trust; (2) copy its Client ID, generate a secret and copy the Secret ID and the secret value; enable the app and the secret; (3) pick or create the Tableau user the screens show, and give it access to only the views screens show: anyone who holds a screen's ticket can embed any view that user can see; (4) run bi connection show ID and enter its embed_origin's host in the connected app's domain allowlist (Only specific domains; Tableau also accepts it with a leading https://). --url is the https address of Tableau Server or of your Tableau Cloud pod (https://10ay.online.tableau.com: the pod, not online.tableau.com) and --site the site's content URL, the part after /site/ or /t/ in a view's address; omit --site for Tableau Server's default site. Tableau Cloud, or Tableau Server 2023.3 or later; the screens must reach the Tableau host, and their browser must accept third-party cookies from it. Run bi connection test next, then bi connection browse to find a view. Connections belong to one project and are not shared. Creating needs Manage access.",
@@ -508,11 +503,4 @@ export function registerGuidance(root: Command): void {
508503
"migrate scan": "Reads the account and writes scan.json in the migration directory (--dir, default ./screenrig-migration-VENDOR, mode 0700); it needs no sign-in and sends nothing to screenRIG. The data is the report: found and importable counts, not_imported (what cannot come over, each with its reason and what to do about it), lossy (what changes on the way) and notes. It exits 0 when the scan completes, even when some items cannot come over. The vendor's key is read from the environment variables migrate vendors VENDOR names, or from --credentials FILE (NAME=VALUE lines); it goes only to that vendor and is never stored, printed or logged, and no flag takes it. --files names a folder or .zip of originals the vendor's API cannot hand out, matched to the vendor's media by file name. A new scan replaces scan.json; the id map next to it stays.",
509504
"migrate apply": "Reads scan.json and imports it into the signed-in project: media into one folder, playlists and layouts, one waiting screen per old screen, and a campaign per schedule that campaigns can express. It never reads the vendor again, except to download media files; --dry-run reads only the project and prints the plan, and needs no vendor key. Safe to rerun: map.json records every object made, bound to this API URL, project, vendor and account (migration_dir_mismatch otherwise), and a rerun reuses what still exists, makes again what was deleted, and never changes what it made. Waiting screens are fleet links of one screen carrying the old name, groups as tags and playlist; the project holds at most 50 links, and the rest wait for a Player code (migrate pair). Opening a link on a Player and running apply again finishes the screen with its old name and time zone and deletes the link. Per-item failures keep ok true with data.failed and warning migration_items_failed. A refusal that would repeat for every item (payment_required, quota_exceeded, campaign_limit_reached, insufficient_access, the vendor refusing the key, a lost connection) ends the run with data.stopped {code, detail, next} and that problem's exit code; run the same command again after fixing it. A vendor file that fails alone (an address that no longer works, an answer that is not a file, a download that goes silent) fails that media only; for an address that no longer works, run migrate scan again to refresh the file addresses. Needs Manage access. Never prints a fleet link URL: fleet-links show ID does.",
510505
"migrate pair": "Claims the code a Player shows for one waiting screen, chosen by its source id or its exact old name (an ambiguous name lists the ids): it pairs the screen under the old name, sets its playlist, time zone and migrated_from field, sets its tags, deletes the waiting screen's link, and records the result. Each step is recorded as it lands, so a rerun finishes what an interrupted pair left. A screen that is already paired is resource_conflict (exit 5) naming it. Run migrate apply first: pairing needs the map and the playlist it made.",
511-
};
512-
for (const [path, note] of Object.entries(notes)) {
513-
const command = findCommand(root, path.split(" "));
514-
if (!command) throw new Error(`Unknown guidance command ${path}`);
515-
addCommandNotes(command, note);
516-
if (path === "comment set") for (const child of command.commands) addCommandNotes(child, note);
517-
}
518-
}
506+
};

‎src/cli-commands/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { registerGuidance } from "./guidance.js";
1+
import { registerGuidance } from "./register-guidance.js";
22
import { registerGlobalOptions } from "./globals.js";
33
import type { CommandActionBinder } from "./types.js";
44
import type { Command } from "commander";
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Command } from "commander";
2+
import { findCommand } from "../command-path.js";
3+
import { addCommandExamples, addCommandNotes, commandExamples } from "./notes.js";
4+
import { GUIDANCE_EXAMPLES, GUIDANCE_NOTES } from "./guidance.js";
5+
6+
/** Attach the help-only examples and notes of guidance.ts to their commands. */
7+
export function registerGuidance(root: Command): void {
8+
for (const [path, suffixes] of Object.entries(GUIDANCE_EXAMPLES)) {
9+
const command = findCommand(root, path.split(" "));
10+
if (!command) throw new Error(`Unknown example command ${path}`);
11+
if (!commandExamples(command).length) addCommandExamples(command, ...suffixes.map(suffix => `screenrig ${path}${suffix ? ` ${suffix}` : ""}`));
12+
}
13+
for (const [path, note] of Object.entries(GUIDANCE_NOTES)) {
14+
const command = findCommand(root, path.split(" "));
15+
if (!command) throw new Error(`Unknown guidance command ${path}`);
16+
addCommandNotes(command, note);
17+
if (path === "comment set") for (const child of command.commands) addCommandNotes(child, note);
18+
}
19+
}

0 commit comments

Comments
 (0)