diff --git a/docs/herdr-insights-development.md b/docs/herdr-insights-development.md index f3a7e26..b30e1c5 100644 --- a/docs/herdr-insights-development.md +++ b/docs/herdr-insights-development.md @@ -62,9 +62,18 @@ and `insights.json`; the automated installer tests cover those boundaries. ## Sharing a build Keep `herdr-insights/` self-contained, with `RECIPE.md`, `INSTALL.md`, `README.md`, -`preview.png`, and `mods/insights/`. Do not ship layouts, key snippets, developer -tests/fixtures, or smoke-test tooling in that folder. The installer copies only -the mod tree, generates the local Node path, and records ownership separately. +`preview.png`, `mods/insights/`, and `mods/insights-launcher/`. Do not ship layouts, +key snippets, developer tests/fixtures, or smoke-test tooling in that folder. +The installer preflights both mod trees, checks temporary buffered copies before +replacing either live mod, generates the dashboard's local Node path, and records +ownership separately for each mod. Install/uninstall share an exclusive lock; +interrupted operations require confirming the process stopped before removing it. +The toolbar is a personal layout clone wrapping Herdr's original `tabs` block; +no built-in mod source is cloned, patched, or vendored. See +[INSTALL.md](../herdr-insights/INSTALL.md) for the clone, click-state, and +ownership rules. A clone does not inherit future structural changes to its +source layout. Validate both mods with `herdr-app check` and inspect the actual +toolbar in the existing window; do not launch a second instance. For Herdr Community discovery, publish the folder in a public GitHub repo and add topic `herdr-mod` after merging to the default branch. Allow up to an hour for diff --git a/herdr-insights/.gitignore b/herdr-insights/.gitignore index 5e87a9b..1cc146e 100644 --- a/herdr-insights/.gitignore +++ b/herdr-insights/.gitignore @@ -6,6 +6,6 @@ coverage/ .artifacts/ # Herdr rewrites these generated files on start; never commit or install them. -mods/insights/**/*.d.ts -mods/insights/**/jsconfig.json +mods/**/*.d.ts +mods/**/jsconfig.json *.error diff --git a/herdr-insights/INSTALL.md b/herdr-insights/INSTALL.md index 1edb852..fe63275 100644 --- a/herdr-insights/INSTALL.md +++ b/herdr-insights/INSTALL.md @@ -1,36 +1,31 @@ # Install Insights -Requires Herdr and **Node.js 22.15+**. Reuse an existing checkout when available. +Requires **Node.js 22.15+** and a running Herdr. Never launch a second Herdr instance. Follow Herdr's `AGENTS.md`; this guide does not override its review requirements. -`RECIPE.md` describes rebuilding, not installation. +Use the existing build, not `RECIPE.md` (rebuilding). ## Install -From the parent of `herdr-insights/`: +From the parent of `herdr-insights/`; no `npm install` needed: ```sh npm run install:local --prefix herdr-insights ``` -Only that folder is needed; no `npm install` is required. The installer copies -the mod, records ownership, configures the local Node path, and runs -`herdr-app check`. Existing usage data, layouts, keys, and terminals stay intact. +App home defaults to `$HERDR_APP_HOME` or `~/.config/herdr-app`. +The installer checks temporary copies of both mods before replacing live files. +First install creates/selects `-insights` (suffixing collisions) +and adds **Insights** beside Files/Changes. Reinstall updates owned mods without +rewriting layouts or changing the selected layout. Shipped files, tabs, terminals, +keys, click state, and usage data are preserved. -Finish when the installer exits successfully with **`herdr-app check passed`**. -Report failures and stop; do not force overwrites or rebuild to bypass errors. -Do not run developer tests or change tabs/focus during installation. Never -launch a second Herdr instance. +Success requires two `herdr-app check passed` messages **and exit code 0**. +Do not run developer tests or change tabs during installation. Open the dashboard +only if requested: click **Insights**, or add `-- --open` to the install command. -## Open only if requested +## Errors -```sh -herdr-app do tab.new '{"name":"Insights","mod":"insights"}' -``` - -## Uninstall - -Usage data and locally edited/unowned files are preserved: - -```sh -npm run uninstall:local --prefix herdr-insights -``` +Stop and report; do not force overwrites or rebuild to bypass errors. The layout +must contain the native `tabs` block. Preserve local edits and any failed layout +clone. Remove the app home's `.insights-install.lock` directory only after +confirming no installer/uninstaller is running. diff --git a/herdr-insights/README.md b/herdr-insights/README.md index c62b7fb..bc84d7f 100644 --- a/herdr-insights/README.md +++ b/herdr-insights/README.md @@ -23,11 +23,13 @@ cd coding-agent-toolkit npm run install:local --prefix herdr-insights ``` -The installer runs `herdr-app check` without opening a tab or test window. -Follow [INSTALL.md](INSTALL.md) for the installation steps. Add `-- --open` only -if you want it to open one **Insights** tab. It installs owned files only under -`mods/insights/` and writes its install manifest to `data/insights/` in -`~/.config/herdr-app`; your layouts, keys, and terminals are unchanged. Set `HERDR_APP_HOME` to use a different app home. +With Herdr running, the installer checks both mods and adds **Insights** beside +**Files / Changes**; clicking it reuses the current space's Insights tab, opening +one only when absent. It installs +`mods/insights/` and `mods/insights-launcher/`, records ownership in +`data/insights/`, and creates a personal layout clone for the button. Shipped +files, keys, terminals, and usage data are unchanged. See [INSTALL.md](INSTALL.md) +for the layout and click-state rules. Set `HERDR_APP_HOME` to use a different app home. ## Use @@ -46,14 +48,19 @@ response text or modify source transcripts. ## Development [RECIPE.md](RECIPE.md) is the self-contained rebuild brief required by Herdr. -The distribution folder is `herdr-insights`; its native mod ID is `insights` -and its display name is **Insights**. No layouts or key bindings ship. +The distribution folder is `herdr-insights`; its dashboard mod ID is `insights` +and its display name is **Insights**. `insights-launcher` is a separate, native +toolbar button that shows the space's existing Insights tab, opening one only +when absent; it has no polling, helper process, or overlays. No layouts or key +bindings ship; the button lives in a personal layout clone composed by the +installer (see [INSTALL.md](INSTALL.md)). Developer tests, fixtures, and release validation live outside this distribution; see the repo's [development guide](../docs/herdr-insights-development.md). They are not installation steps. -Uninstall while keeping usage data: +With Herdr running, uninstall while keeping usage data and returning to the +original layout: ```sh npm run uninstall:local --prefix herdr-insights diff --git a/herdr-insights/RECIPE.md b/herdr-insights/RECIPE.md index 99b9302..6424d19 100644 --- a/herdr-insights/RECIPE.md +++ b/herdr-insights/RECIPE.md @@ -2,29 +2,44 @@ A native Herdr dashboard for token usage across Pi, Codex, Claude Code, and OpenCode. It reads existing local session data, shows model rankings and activity, -and opens in its own tab without changing terminals, layouts, or keys. +and opens in its own tab from an Insights button beside Files and Changes. No +built-in Herdr code, terminals, or keys are changed. ![Insights](preview.png) Installing this existing build? Follow [INSTALL.md](INSTALL.md) and the user's Herdr `AGENTS.md`. This recipe is a rebuild brief; its **Done when** checks apply to building/releasing, not routine installation. Installation does not launch -smoke-test windows or open a tab unless requested. +smoke-test windows or open a dashboard tab unless requested. First installation +selects a personal layout with the button in the existing toolbar row. **What you see:** A vertically scrollable dashboard centered at a maximum width of 1180px. Use Herdr theme colors, 1px borders, 8px card corners, 6px control corners, no decorative shadows, and 4/8/12/16/24px spacing. Page padding is 12px below 620px, 16px below 900px, and 24px otherwise. Support light and dark themes. -Top to bottom: +A separate `insights-launcher` mod draws the **Insights** toolbar button after +Files and Changes. Match Herdr's panel controls: 26px height, 8px horizontal +padding, 5px icon/text gap, 6px corners, 12px type, 14px theme-tinted chart icon, +chrome background, muted text, and native hover colors. Its root matches the tab +bar's chrome background and 1px bottom border. Stop mouse-down propagation on +the button so it does not drag the window. Every click shows the current space's +existing Insights tab (the tab whose pane runs the `insights` mod) instead of +stacking duplicates. Focus its pane with `pane.focus`; if a different pane in +that tab is zoomed, unzoom it first so the dashboard is visible. It calls `tab.new` with `{name: "Insights", mod: "insights"}` +only when that space has none. No floating window, overlay, polling, file access, +or helper process. + +Dashboard, top to bottom: - An Insights header with last-update status, Refresh, and Settings. - Five all-time metrics: tokens, peak-day tokens, longest task, longest streak, and current streak. Use 22px values and small uppercase labels; unknown duration is a dash. The strip wraps from five columns to three or two on smaller views. - Token usage: a 53-week, seven-row local-calendar heatmap with month/weekday labels and Daily / Weekly / Cumulative controls. Cells are 12–20px squares - fitted to available width, with 2px gaps; narrow views scroll horizontally. - Future days are dimmed. Clicking a day updates the detail card. + fitted to available width, with 2px gaps; when even 12px cells cannot fit, the + grid overflows and stays aligned to the newest weeks. Future days are dimmed. + Clicking a day updates the detail card. - Side-by-side all-time and selected-day details: input, output, cache-hit rate, and top model. Default the selected day to today. - Model usage: Week / Month / Year / All controls, a year stepper for Year, @@ -32,10 +47,12 @@ Top to bottom: - Top models: a 26-week stacked token chart with date labels, a token axis, and a categorical legend. Keep seven model series and fold the rest into Other. - Model Usage: the top ten models with provider attribution, compact token totals, - and change versus the previous calendar period. Use two columns at 960px+, one - below. Show a known model logo, else a provider logo, else an initial. Up/down - arrows and theme success/destructive colors distinguish trends; handle New and - no-comparison states. Rows do not expand. + and change versus the previous calendar period. Two columns of five that wrap to + one when the pane cannot hold both; use a 300px flex basis, growth, and zero + minimum width so a column can shrink inside narrow splits. Show a known model + logo, else a provider logo, else an initial. Up/down arrows and theme + success/destructive colors distinguish trends; handle New and no-comparison + states. Rows do not expand. Settings replaces the dashboard content. It contains palette (blue/green/purple), week start, include-cache, refresh interval, per-source toggles and root editors, @@ -58,12 +75,32 @@ controls preserve custom mappings. Changing source configuration invalidates the scan cache; palette/cache/week-start/refresh changes preserve collected history. **Built from:** Put this distribution in `herdr-insights/`, with `RECIPE.md`, -`README.md`, `INSTALL.md`, `preview.png`, and `mods/insights/`. Keep developer +`README.md`, `INSTALL.md`, `preview.png`, `mods/insights/`, and +`mods/insights-launcher/`. Keep developer tests, fixtures, and release-check tooling outside this distribution folder. Do not include a `layouts/` folder or keys snippet: no bindings are needed. The recipe heading and `mod.json` title are **Insights**; the native mod ID is `insights`. `mods/insights/mod.json` contains `{"place":"none","title":"Insights"}`. +`mods/insights-launcher/mod.json` also uses `place: "none"`, `title: "Insights"`. +Its `main.js` default-exports a small native View using gpui-base flex builders, +`theme()`, and `run()`; bundle its chart icon as `insights.svg`. + +`scripts/toolbar.mjs` integrates through layout composition, not source patching. +Use `layout.clone {name: current, as: current + "-insights"}` to preserve Herdr's +appearance/click state. Before cloning, reject symlinked source state or `.state` +parents; choose a numeric suffix if either the destination layout or its state +already exists. Never overwrite another layout's state, including leftover state +from uninstall. Do not edit or remove Herdr-managed click state. In the personal clone only, turn the existing `tabs` +block into a nonresizable row, keeping its ID, height, paint, and window-drag +attributes. Its first child is a weight-1 block with the original `content` +(including its props); its second is a 104px block with `insights-launcher`. +Do not copy or modify the shipped `tabs` mod. Refuse unsupported/ambiguous layouts. +Record the personal layout's name, source, and hash in `toolbar-install.json`. +Reinstall must not rewrite this layout or switch the user's selected layout. +Uninstall validates the original layout and confirms it actually became selected +before deleting an active, unchanged owned clone. Locally edited clones, missing +originals, or unsuccessful switching block removal before deleting any files. `main.js` default-exports a gpui-kit `View`; use gpui-base flex/controls, `theme()` semantic colors, `on_size_change`, and fresh callback contexts. Do not retain `cx`. Cancel tasks/timers and release native editor/input state on close. Use native @@ -99,13 +136,21 @@ and executable mappings. `core/config.mjs` defines and validates the full schema Provide dependency-free Node install/uninstall scripts. The installer requires Node >=22.15 and validates it before writing anything; the uninstaller intentionally -has no Node gate so it remains usable on older runtimes. Install only the mod tree -(plus its manifest under `data/insights/`) and generate `runtime.json` with the +has no Node gate so it remains usable on older runtimes. Install only these two +mod trees (plus ownership manifests under `data/insights/`) and generate the dashboard's `runtime.json` with the absolute installer Node path; the UI uses that path, falling back to `node` on PATH only for manual copies. Track ownership/hashes in a separate install manifest. -Refuse symlinks and unowned collisions; refuse edited owned files unless explicitly -forced. Preserve usage data and unrelated files. Run `herdr-app check` and, only -with `--open`, `herdr-app do tab.new '{"name":"Insights","mod":"insights"}'`. +Keep the existing dashboard manifest compatible and use +`launcher-install-manifest.json` for the launcher. Preflight both targets before +writing either. Refuse symlinks and unowned collisions; refuse edited owned files +unless explicitly forced. Preserve usage data and unrelated files. Check buffered +copies of both mods with `herdr-app check` in a temporary app home before replacing +either live mod; clean up on success or failure. Revalidate targets after checking +to preserve edits made during validation. Serialize install/uninstall with an +exclusive `.insights-install.lock` directory for the full operation, and never +steal a lock automatically. Recheck clone destinations and source state just before +cloning, and recheck the active layout and clone hash before deleting on uninstall. +Keep Herdr running for toolbar installation/removal; never start a second instance. **Watch out:** Canonical total is input + output + cacheRead + cacheWrite; input is uncached, output already includes reasoning. Never add reasoning twice. @@ -118,17 +163,23 @@ explicit rebuild after same-size rewrites. Use ownership-checked store locks; never steal live/unknown owners' locks based on age. Preserve corrupt stores with a backup before rebuilding; read-only prepare must not repair or create stores. -Never write layouts, keys, source transcripts, or an existing user's usage data -from the installer. Do not copy an absolute runtime path from another machine. +Never modify shipped layouts/mods, keys, source transcripts, or existing usage +data. Write only the owned personal layout clone. Preserve collisions and local +edits. Do not copy an absolute runtime path from another machine. **Done when:** For a new build or release (not an installation), package tests and checks pass, including distribution naming, PNG preview, recipe structure, no layouts, token accounting, deduplication, configuration validation, incremental recovery, and safe installation/uninstallation. -Run `herdr-app check` on the installed mod, plus populated-render and asynchronous +Run `herdr-app check` on both installed mods. Inspect a real screenshot: Insights +must be in the same row as Files/Changes with matching button styling, no extra +strip or floating window. Clicks reuse the current space's dashboard, including +when it is in a split behind another pane's zoom, without creating duplicates. +Confirm built-in +code/layouts are unchanged and reinstall does not duplicate the toolbar. Also run populated-render and asynchronous native smoke checks in temporary app homes. Inspect a native screenshot and status for render errors. Verify actual filters, heatmap modes, source settings, refresh, -and empty/error states. Reinstall without changing source transcripts, layouts, -keys, local edits, or `insights.json`. Keep the GitHub repo public and add topic +and empty/error states. Reinstall without changing source transcripts, existing +layouts, keys, local edits, or `insights.json`. Keep the GitHub repo public and add topic `herdr-mod` for Community discovery after merging to the default branch; allow up to an hour for indexing and at least 24 hours for picture screening. diff --git a/herdr-insights/mods/insights-launcher/insights.svg b/herdr-insights/mods/insights-launcher/insights.svg new file mode 100644 index 0000000..aa045df --- /dev/null +++ b/herdr-insights/mods/insights-launcher/insights.svg @@ -0,0 +1 @@ + diff --git a/herdr-insights/mods/insights-launcher/main.js b/herdr-insights/mods/insights-launcher/main.js new file mode 100644 index 0000000..848e34a --- /dev/null +++ b/herdr-insights/mods/insights-launcher/main.js @@ -0,0 +1,36 @@ +// Composed beside Herdr's original tabs mod; never copies or patches its code. +import { View, svg } from "gpui-kit"; +import { h_flex } from "gpui-base"; +import { panes, run, spaces, theme } from "herdr"; + +const TAB = { name: "Insights", mod: "insights" }; + +/** One Insights tab per space: show it if it exists, otherwise create it. */ +function showInsights() { + const active = spaces().find((space) => space.active); + const tabIds = new Set(active?.tabs.map((tab) => tab.id) ?? []); + const open = panes().find((pane) => pane.mod === TAB.mod && tabIds.has(pane.tab)); + if (open) { + // Selecting a tab alone keeps its previous focus/zoom, which may hide this + // pane behind a terminal. Reveal the dashboard without changing split sizes. + const zoomed = panes().find((pane) => pane.tab === open.tab && pane.zoomed); + if (zoomed && zoomed.pane !== open.pane) run("pane.zoom", { pane: zoomed.pane }); + return run("pane.focus", { pane: open.pane }); + } + return run("tab.new", TAB); +} + +export default class InsightsLauncher extends View { + render() { + const t = theme(); + return h_flex().size_full().pr(10).items_center() + .bg(t.chrome).border_b(1).border_color(t.border) + .child(h_flex().id("insights-open").h(26).px(8).gap(5).flex_none() + .items_center().rounded(6).text_size(12).bg(t.chrome).text_color(t.muted) + .hover((style) => style.bg(t.hover).text_color(t.foreground)) + .on_mouse_down("left", (_event, cx) => cx.stop_propagation()) + .on_click(() => showInsights()) + .child(svg("mods/insights-launcher/insights.svg").size(14)) + .child("Insights")); + } +} diff --git a/herdr-insights/mods/insights-launcher/mod.json b/herdr-insights/mods/insights-launcher/mod.json new file mode 100644 index 0000000..7ef7f22 --- /dev/null +++ b/herdr-insights/mods/insights-launcher/mod.json @@ -0,0 +1 @@ +{ "place": "none", "title": "Insights" } diff --git a/herdr-insights/mods/insights/main.js b/herdr-insights/mods/insights/main.js index 909dd20..4d414d0 100644 --- a/herdr-insights/mods/insights/main.js +++ b/herdr-insights/mods/insights/main.js @@ -49,10 +49,16 @@ const MIN_CELL = 12; const MAX_CELL = 20; const CELL_GAP = 2; const DAY_LABEL_WIDTH = 30; +// A leaderboard row needs its rank, avatar, a readable model name, and the token +// value. A preferred basis wraps columns without imposing a minimum wider than +// a narrow split pane; each column can still shrink to its available width. +const LEADERBOARD_COLUMN_BASIS = 300; /** * Heatmap cells stay small on narrow views and grow up to MAX_CELL on wide - * ones so the grid reads centered instead of pinned to the left edge. + * ones so the grid reads centered instead of pinned to the left edge. When even + * MIN_CELL cannot fit, the grid overflows; callers keep the newest weeks in + * view by aligning it to the end. */ export function heatmapMetrics(width, weekCount) { const viewWidth = Number.isFinite(width) ? width : 960; @@ -1627,8 +1633,13 @@ export default class Insights extends View { this.heatmapMode = key; this.scheduleDashboard(changeCx, { force: true }); }, c))) - .child(v_flex().w_full().overflow_x_scrollbar() - .child(h_flex().w_full().when(metrics.centered, (el) => el.justify_center()).child(grid))) + // The latest data matters most: when the grid cannot fit, it is clipped on + // the left so the visible columns are the most recent weeks. A scroll + // container would size to the grid and defeat the end alignment. + .child(h_flex().w_full().overflow_hidden() + .when(metrics.centered, (el) => el.justify_center()) + .when(!metrics.centered, (el) => el.justify_end()) + .child(grid)) .child(legend); } @@ -1704,7 +1715,6 @@ export default class Insights extends View { const half = Math.ceil(entries.length / 2); const left = entries.slice(0, half); const right = entries.slice(half); - const twoColumns = (this.width || 960) >= 960 && right.length > 0; const changeLabel = (entry) => { if (entry.change.kind === "none") return "—"; @@ -1713,10 +1723,10 @@ export default class Insights extends View { return entry.change.label; }; - const row = (entry, last) => { + const row = (entry) => { const logo = usageLogo(entry.name, entry.provider); return h_flex().w_full().items_center().gap(G.sm).py(G.sm) - .when(!last, (el) => el.border_b(1).border_color(c.border)) + .border_b(1).border_color(c.border) .child(div().w(20).text_size(T.body).text_color(c.dim).child(`${entry.rank}.`)) .child(h_flex().size(26).flex_none().rounded(R.control).bg(c.secondary).border_1().border_color(c.border) .items_center().justify_center() @@ -1742,6 +1752,9 @@ export default class Insights extends View { clickCx.notify(); }, c, { variant: "outline" })); + const column = (items) => v_flex().flex_basis(LEADERBOARD_COLUMN_BASIS).flex_grow(1).min_w(0).gap(0) + .children(items.map((entry) => row(entry))); + return card(c).gap(G.md) .child(cardHeader( "Model Usage", @@ -1749,11 +1762,9 @@ export default class Insights extends View { c, )) .when(entries.length === 0, (el) => el.child(empty)) - .when(entries.length > 0 && twoColumns, (el) => el.child(h_flex().w_full().gap(G.xl).items_start() - .child(v_flex().flex_1().min_w(0).gap(0).children(left.map((entry, index) => row(entry, index === left.length - 1)))) - .child(v_flex().flex_1().min_w(0).gap(0).children(right.map((entry, index) => row(entry, index === right.length - 1)))))) - .when(entries.length > 0 && !twoColumns, (el) => el.child(v_flex().w_full().gap(0) - .children(entries.map((entry, index) => row(entry, index === entries.length - 1))))) + .when(entries.length > 0, (el) => el.child(h_flex().w_full().flex_wrap().items_start().gap_x(G.xl) + .child(column(left)) + .when(right.length > 0, (inner) => inner.child(column(right))))) .when(leaders.totalGroups > entries.length, (el) => el.child(mutedText( `Showing top ${entries.length} of ${leaders.totalGroups} by tokens.`, c))); } diff --git a/herdr-insights/scripts/install.mjs b/herdr-insights/scripts/install.mjs index 45baa90..b25325f 100644 --- a/herdr-insights/scripts/install.mjs +++ b/herdr-insights/scripts/install.mjs @@ -1,8 +1,11 @@ import { promises as fs } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { PACKAGE_ROOT, + MODS, + MOD_NAMES, RUNTIME_NAME, assertNoSymlinkComponents, assertSupportedNodeVersion, @@ -23,9 +26,10 @@ import { sha256, writeFileAtomic, writeManifest, + withInstallerLock, } from "./lib.mjs"; -const OPEN_ARGS = ["do", "tab.new", '{"name":"Insights","mod":"insights"}']; +import { prepareToolbar, applyToolbar } from "./toolbar.mjs"; function usage() { return `Insights local installer @@ -35,14 +39,15 @@ Usage: node scripts/install.mjs [options] Options: - --open Open the Insights tab after herdr-app check passes + --open Open an Insights tab after installation (optional) --force Replace owned files edited locally (never unowned files or links) --source Source package folder containing mods/insights (development) --app-home Herdr app home (default: $HERDR_APP_HOME or ~/.config/herdr-app) -h, --help Show this help -The installer copies only mods/insights, creates data/insights, and never writes -layouts, keys, shipped mods, or insights.json. It generates mods/insights/runtime.json +The installer copies mods/insights and mods/insights-launcher and creates a personal +layout with an Insights button beside the original tabs mod. It never edits shipped +layouts/mods, keys, terminals, or insights.json. It generates mods/insights/runtime.json with the absolute Node executable used for installation. It does not publish or use the network. `; @@ -124,24 +129,26 @@ async function addParentIssues(targetDirectory, target, issues) { } } -async function install(options) { - assertSupportedNodeVersion(); - const sourceRoot = options.source - ? path.resolve(expandHome(options.source)) - : PACKAGE_ROOT; - const source = await collectSourceFiles(sourceRoot); - // The installer owns runtime.json: never install a source copy, even if one - // exists, and always point the UI at the Node executable that ran install. - source.files = source.files.filter((file) => file.relative !== RUNTIME_NAME); - source.files.push(createRuntimeFile()); - source.files.sort((left, right) => left.relative.localeCompare(right.relative)); - - // Validate the source before creating the app home. A bad --source must not - // leave a new target directory behind. - const appHome = await resolveAppHome(options.appHome); - const targetDirectory = modDir(appHome); +async function collectInstallSources(sourceRoot) { + const sources = new Map(); + for (const modName of MOD_NAMES) { + if (MODS[modName].optional && !await lstatOrNull(path.join(sourceRoot, MODS[modName].rel))) continue; + const source = await collectSourceFiles(sourceRoot, modName); + // Only the dashboard needs Node; the launcher runs entirely inside Herdr. + if (MODS[modName].runtime) { + source.files = source.files.filter((file) => file.relative !== RUNTIME_NAME); + source.files.push(createRuntimeFile()); + } + source.files.sort((left, right) => left.relative.localeCompare(right.relative)); + sources.set(modName, source); + } + return sources; +} + +async function prepareInstall(appHome, source, modName, force) { + const targetDirectory = modDir(appHome, modName); const targetDataDirectory = dataDir(appHome); - const targetManifest = manifestPath(appHome); + const targetManifest = manifestPath(appHome, modName); if (path.resolve(source.modDir) === path.resolve(targetDirectory)) { throw new Error("Source and target are the same mod folder; refusing to install over itself."); @@ -157,7 +164,7 @@ async function install(options) { } } - const manifest = await readManifest(appHome); + const manifest = await readManifest(appHome, modName); if (targetStat && !manifest) { throw new Error( `Refusing an unowned collision at ${targetDirectory}.\nNo ${targetManifest} proves this installer owns it. Move the folder aside or install with Herdr's own mod tools.`, @@ -188,7 +195,7 @@ async function install(options) { } const currentHash = sha256(await fs.readFile(target)); - if (currentHash !== oldHash && currentHash !== file.hash && !options.force) { + if (currentHash !== oldHash && currentHash !== file.hash && !force) { issues.push( `Owned file was edited locally: ${target} (use --force to replace it with reviewed source)`, ); @@ -203,6 +210,38 @@ async function install(options) { ); } + return { appHome, source, targetDirectory, targetDataDirectory, manifest, sourcePaths, modName }; +} + +// Validate the exact buffered source (including runtime.json) before replacing +// either live mod. A broken launcher must not partially upgrade the dashboard. +async function checkInstallSources(sources) { + const staging = await fs.mkdtemp(path.join(os.tmpdir(), "insights-install-check-")); + try { + for (const [modName, source] of sources) { + for (const file of source.files) { + const target = localPath(modDir(staging, modName), file.relative); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, file.data, { mode: file.mode }); + } + } + for (const modName of sources.keys()) { + const check = await runHerdr(["check", modDir(staging, modName)], { + env: { HERDR_APP_HOME: staging, HOME: staging }, + }); + relayHerdrOutput(check); + if (check.error || check.code !== 0 || check.signal) { + throw new Error(`${herdrFailure(check, "herdr-app check")}\nNo installed mod files were changed.`); + } + console.log(`herdr-app check passed (${modName}, staged source).`); + } + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } +} + +async function applyInstall(plan) { + const { appHome, source, targetDirectory, targetDataDirectory, manifest, sourcePaths, modName } = plan; await ensureDirectory(appHome, path.join(appHome, "mods"), "Herdr mods directory"); await ensureDirectory(appHome, targetDirectory, "Insights mod directory"); await ensureDirectory(appHome, targetDataDirectory, "Insights data directory", 0o700); @@ -268,7 +307,7 @@ async function install(options) { ...retainedHashes, ...Object.fromEntries(source.files.map((file) => [file.relative, file.hash])), }; - await writeManifest(appHome, installedHashes); + await writeManifest(appHome, installedHashes, modName); console.log( `Installed ${source.files.length} owned file(s) to ${targetDirectory} ` + @@ -276,30 +315,13 @@ async function install(options) { removed.length ? `, ${removed.length} stale removed` : "" }).`, ); - console.log(`Generated ${RUNTIME_NAME} for Node ${process.execPath}.`); - console.log(`Insights data stays at ${targetDataDirectory}; insights.json is untouched.`); + if (MODS[modName].runtime) { + console.log(`Generated ${RUNTIME_NAME} for Node ${process.execPath}.`); + console.log(`Insights data stays at ${targetDataDirectory}; insights.json is untouched.`); + } for (const kept of keptStale) { console.warn(`Kept an owned file no longer in the source: ${kept}`); } - - const herdrEnv = { HERDR_APP_HOME: appHome }; - const check = await runHerdr(["check", targetDirectory], { env: herdrEnv }); - relayHerdrOutput(check); - if (check.error || check.code !== 0 || check.signal) { - throw new Error( - `${herdrFailure(check, "herdr-app check")}\nFiles are installed; fix the mod and rerun the installer.`, - ); - } - console.log("herdr-app check passed."); - - if (options.open) { - const opened = await runHerdr(OPEN_ARGS, { env: herdrEnv }); - relayHerdrOutput(opened); - if (opened.error || opened.code !== 0 || opened.signal) { - throw new Error(herdrFailure(opened, "herdr-app do tab.new")); - } - console.log("Opened the Insights tab in the current Herdr space."); - } } async function main() { @@ -318,7 +340,41 @@ async function main() { } try { - await install(options); + assertSupportedNodeVersion(); + const sourceRoot = options.source ? path.resolve(expandHome(options.source)) : PACKAGE_ROOT; + // Validate every source before creating the app home. A bad --source must + // not leave a new target directory behind. + const sources = await collectInstallSources(sourceRoot); + const appHome = await resolveAppHome(options.appHome); + await withInstallerLock(appHome, async () => { + // Preflight every target before changing either mod. + const plans = []; + for (const [modName, source] of sources) { + plans.push(await prepareInstall(appHome, source, modName, options.force)); + } + const toolbar = sources.has("insights-launcher") ? await prepareToolbar(appHome) : null; + await checkInstallSources(sources); + // Native checks yield to the running app. Refuse edits/collisions made + // during validation before replacing any live files. + for (let index = 0; index < plans.length; index += 1) { + const plan = plans[index]; + plans[index] = await prepareInstall(appHome, plan.source, plan.modName, options.force); + } + for (const plan of plans) await applyInstall(plan); + if (toolbar) await applyToolbar(toolbar); + // Keep the previously documented flag working for existing install scripts. + // Default installation still never opens a tab. + if (options.open) { + const opened = await runHerdr(["do", "tab.new", '{"name":"Insights","mod":"insights"}'], { + env: { HERDR_APP_HOME: appHome }, + }); + relayHerdrOutput(opened); + if (opened.error || opened.code !== 0 || opened.signal) { + throw new Error(`${herdrFailure(opened, "Opening Insights")}\nFiles are installed; open the tab from the toolbar.`); + } + console.log("Opened the Insights tab."); + } + }); return 0; } catch (error) { console.error(`Error: ${error.message}`); diff --git a/herdr-insights/scripts/lib.mjs b/herdr-insights/scripts/lib.mjs index 5261a26..3e90d71 100644 --- a/herdr-insights/scripts/lib.mjs +++ b/herdr-insights/scripts/lib.mjs @@ -10,10 +10,16 @@ export const PACKAGE_ROOT = path.resolve( "..", ); export const MIN_NODE_VERSION = "22.15"; -export const MOD_REL = "mods/insights"; -export const DATA_REL = "data/insights"; -export const MANIFEST_NAME = "install-manifest.json"; export const RUNTIME_NAME = "runtime.json"; +export const MODS = Object.freeze({ + insights: { manifest: "install-manifest.json", rel: "mods/insights", runtime: true }, + "insights-launcher": { + manifest: "launcher-install-manifest.json", + optional: true, + rel: "mods/insights-launcher", + }, +}); +export const MOD_NAMES = Object.keys(MODS); export const MANIFEST_VERSION = 1; export const MANIFEST_OWNER = "herdr-insights"; export const HASH_RE = /^[0-9a-f]{64}$/; @@ -48,16 +54,22 @@ export function createRuntimeFile(nodePath = process.execPath) { }; } -export function modDir(appHome) { - return path.join(appHome, "mods", "insights"); +export function modInfo(modName) { + const info = MODS[modName]; + if (!info) throw new Error(`Unknown Insights mod: ${modName}`); + return info; +} + +export function modDir(appHome, modName) { + return path.join(appHome, modInfo(modName).rel); } export function dataDir(appHome) { return path.join(appHome, "data", "insights"); } -export function manifestPath(appHome) { - return path.join(dataDir(appHome), MANIFEST_NAME); +export function manifestPath(appHome, modName) { + return path.join(dataDir(appHome), modInfo(modName).manifest); } export async function lstatOrNull(target) { @@ -180,6 +192,23 @@ export async function assertNoSymlinkComponents(root, target) { } } +/** Serialize installers/uninstallers, including preflight and native commands. */ +export async function withInstallerLock(appHome, action) { + if (!appHome) return action(); + const lock = path.join(appHome, ".insights-install.lock"); + try { + await fs.mkdir(lock, { mode: 0o700 }); + } catch (error) { + if (error.code !== "EEXIST") throw error; + throw new Error(`Insights install/uninstall is locked at ${lock}. Wait for the other installer; if it was interrupted, remove this empty lock directory only after confirming it has stopped.`, { cause: error }); + } + try { + return await action(); + } finally { + await fs.rmdir(lock); + } +} + export async function ensureDirectory(root, target, label = "directory", mode) { await assertNoSymlinkComponents(root, target); const existing = await lstatOrNull(target); @@ -198,21 +227,21 @@ export async function ensureDirectory(root, target, label = "directory", mode) { } } -export async function readManifest(appHome) { +export async function readManifest(appHome, modName) { try { - return await readManifestData(appHome); + return await readManifestData(appHome, modName); } catch (error) { throw new Error( `${error.message}\nNo target files were changed. To recover, preserve or move aside both ` + - `${modDir(appHome)} and ${manifestPath(appHome)}, then reinstall. ` + + `${modDir(appHome, modName)} and ${manifestPath(appHome, modName)}, then reinstall. ` + `Leave ${path.join(dataDir(appHome), "insights.json")} in place.`, { cause: error }, ); } } -async function readManifestData(appHome) { - const file = manifestPath(appHome); +async function readManifestData(appHome, modName) { + const file = manifestPath(appHome, modName); await assertNoSymlinkComponents(appHome, file); const stat = await lstatOrNull(file); if (!stat) return null; @@ -241,7 +270,7 @@ async function readManifestData(appHome) { if (parsed.owner !== MANIFEST_OWNER) { throw new Error(`Install manifest belongs to another owner: ${String(parsed.owner)}`); } - if (parsed.target !== MOD_REL) { + if (parsed.target !== modInfo(modName).rel) { throw new Error(`Install manifest targets another folder: ${String(parsed.target)}`); } if (!parsed.files || typeof parsed.files !== "object" || Array.isArray(parsed.files)) { @@ -260,7 +289,7 @@ async function readManifestData(appHome) { return { ...parsed, files, path: file }; } -export async function writeManifest(appHome, files) { +export async function writeManifest(appHome, files, modName) { const directory = dataDir(appHome); await ensureDirectory(appHome, directory, "Insights data directory"); @@ -279,11 +308,11 @@ export async function writeManifest(appHome, files) { const manifest = { version: MANIFEST_VERSION, owner: MANIFEST_OWNER, - target: MOD_REL, + target: modInfo(modName).rel, installedAt: new Date().toISOString(), files: sortedFiles, }; - const file = manifestPath(appHome); + const file = manifestPath(appHome, modName); await assertNoSymlinkComponents(appHome, file); await writeFileAtomic(file, `${JSON.stringify(manifest, null, 2)}\n`, 0o644); return manifest; @@ -331,12 +360,13 @@ function isGeneratedSourceFile(name) { return name === "jsconfig.json" || name.endsWith(".d.ts"); } -export async function collectSourceFiles(sourceRoot) { - const declaredModDir = path.join(path.resolve(sourceRoot), MOD_REL); +export async function collectSourceFiles(sourceRoot, modName) { + const relativeMod = modInfo(modName).rel; + const declaredModDir = path.join(path.resolve(sourceRoot), relativeMod); const declaredStat = await lstatOrNull(declaredModDir); if (!declaredStat) { throw new Error( - `Source folder not found: ${declaredModDir}\nExpected ${MOD_REL}/ with main.js, mod.json (place "none") and the collector.`, + `Source folder not found: ${declaredModDir}\nExpected ${relativeMod}/ with main.js and mod.json.`, ); } if (declaredStat.isSymbolicLink()) { @@ -419,9 +449,7 @@ export async function collectSourceFiles(sourceRoot) { throw new Error("Source mod.json must contain a JSON object."); } if (modJson.place !== "none") { - throw new Error( - 'Source mod.json must use "place": "none"; Insights opens through the installed mod tab, not as a docked panel.', - ); + throw new Error(`Source ${relativeMod}/mod.json must use "place": "none".`); } return { files, modDir: root, modJson }; diff --git a/herdr-insights/scripts/toolbar.mjs b/herdr-insights/scripts/toolbar.mjs new file mode 100644 index 0000000..e5bd034 --- /dev/null +++ b/herdr-insights/scripts/toolbar.mjs @@ -0,0 +1,186 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { + assertNoSymlinkComponents, dataDir, ensureDirectory, HASH_RE, herdrFailure, + lstatOrNull, runHerdr, sha256, writeFileAtomic, +} from "./lib.mjs"; + +const RECORD = "toolbar-install.json"; +const OWNER = "herdr-insights-toolbar"; +const BUTTON_ID = "insights-toolbar-button"; +const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + +function assertName(name) { + if (typeof name !== "string" || !NAME.test(name)) throw new Error(`Unsupported layout name: ${JSON.stringify(name)}`); +} + +function layoutFile(appHome, name) { + assertName(name); + return path.join(appHome, "layouts", `${name}.json`); +} + +function stateFile(appHome, name) { + assertName(name); + return path.join(appHome, "layouts", ".state", `${name}.json`); +} + +async function readRegular(appHome, file) { + await assertNoSymlinkComponents(appHome, file); + const stat = await lstatOrNull(file); + if (!stat) return null; + if (!stat.isFile()) throw new Error(`Not a regular file: ${file}`); + return fs.readFile(file, "utf8"); +} + +async function readRecord(appHome) { + const file = path.join(dataDir(appHome), RECORD); + const text = await readRegular(appHome, file); + if (text === null) return null; + const record = JSON.parse(text); + if (record?.version !== 1 || record.owner !== OWNER || + typeof record.source !== "string" || !NAME.test(record.source) || + typeof record.layout !== "string" || !NAME.test(record.layout) || + !record.layout.startsWith(`${record.source}-insights`) || + typeof record.hash !== "string" || !HASH_RE.test(record.hash)) { + throw new Error(`Invalid toolbar ownership record: ${file}`); + } + return record; +} + +async function command(appHome, args) { + const result = await runHerdr(args, { env: { HERDR_APP_HOME: appHome } }); + if (result.error || result.code !== 0 || result.signal) { + throw new Error(herdrFailure(result, `herdr-app ${args.slice(0, 2).join(" ")}`)); + } + return result.stdout; +} + +/** Preserve the original tabs mod and its updates; compose one button beside it. */ +export function withInsightsToolbar(layout) { + const copy = structuredClone(layout); + const nodes = []; + function walk(node) { + nodes.push(node); + for (const child of node.children ?? []) walk(child); + } + walk(copy); + if (nodes.some((node) => node.id === BUTTON_ID || node.id === "insights-original-tabs")) { + throw new Error("This layout already contains Insights toolbar blocks."); + } + const matches = nodes.filter((node) => node.id === "tabs" && node.type === "block" && node.content?.mod === "tabs"); + if (matches.length !== 1) { + throw new Error('Expected one native "tabs" block. This layout cannot be integrated safely.'); + } + const tabs = matches[0]; + const content = tabs.content; + delete tabs.content; + tabs.type = "row"; + tabs.resizable = false; + tabs.children = [ + { id: "insights-original-tabs", type: "block", size: { weight: 1 }, content }, + { id: BUTTON_ID, type: "block", size: { px: 104 }, content: { mod: "insights-launcher" } }, + ]; + return copy; +} + +export async function prepareToolbar(appHome) { + const record = await readRecord(appHome); + if (record) { + const file = layoutFile(appHome, record.layout); + if (await readRegular(appHome, file) === null) throw new Error(`Owned toolbar layout is missing: ${file}`); + // Reinstall never rewrites a personal layout or changes the chosen layout. + return { appHome, record }; + } + const status = JSON.parse(await command(appHome, ["status"])); + const source = status.layout; + const sourceFile = layoutFile(appHome, source); + const original = await readRegular(appHome, sourceFile); + if (original === null) throw new Error(`Layout not found: ${sourceFile}`); + withInsightsToolbar(JSON.parse(original)); // Validate before cloning or writing mods. + // Native cloning also copies click state. Never let it follow a state link, + // overwrite another layout's state, or destroy state left from an uninstall. + await readRegular(appHome, stateFile(appHome, source)); + for (let suffix = 1; suffix <= 100; suffix += 1) { + const layout = `${source}-insights${suffix === 1 ? "" : `-${suffix}`}`; + const file = layoutFile(appHome, layout); + const state = stateFile(appHome, layout); + await assertNoSymlinkComponents(appHome, path.dirname(state)); + if (await lstatOrNull(file) || await lstatOrNull(state)) continue; + return { appHome, source, layout, file }; + } + throw new Error("No free Insights layout name; existing layouts and click state were preserved."); +} + +export async function applyToolbar(plan) { + if (plan.record) return; + const { appHome, source, layout, file } = plan; + // Checks may have taken seconds. Revalidate clone inputs immediately before + // Herdr can copy state (its native clone may overwrite destination state). + await readRegular(appHome, stateFile(appHome, source)); + const state = stateFile(appHome, layout); + await assertNoSymlinkComponents(appHome, state); + await assertNoSymlinkComponents(appHome, file); + if (await lstatOrNull(file) || await lstatOrNull(state)) { + throw new Error(`Toolbar destination appeared during install: ${layout}; no layout or click state replaced.`); + } + await command(appHome, ["do", "layout.clone", JSON.stringify({ name: source, as: layout })]); + try { + // The supported clone command preserves Herdr's click/appearance state. + const cloned = await readRegular(appHome, file); + if (cloned === null) throw new Error(`Herdr did not create ${file}`); + const text = `${JSON.stringify(withInsightsToolbar(JSON.parse(cloned)), null, 2)}\n`; + await writeFileAtomic(file, text); + await ensureDirectory(appHome, dataDir(appHome), "Insights data directory"); + await writeFileAtomic(path.join(dataDir(appHome), RECORD), `${JSON.stringify({ + version: 1, owner: OWNER, source, layout, hash: sha256(text), + }, null, 2)}\n`); + } catch (error) { + // Do not leave a failed integration selected; keep the clone for inspection. + try { await command(appHome, ["do", "layout.switch", JSON.stringify({ name: source })]); } catch { /* retain original failure */ } + throw error; + } + console.log(`Added Insights beside Files/Changes in personal layout ${layout}; Herdr's tabs mod is unchanged.`); +} + +export async function prepareToolbarRemoval(appHome) { + if (!appHome) return null; + const record = await readRecord(appHome); + if (!record) return null; + const file = layoutFile(appHome, record.layout); + const text = await readRegular(appHome, file); + if (text !== null && sha256(text) !== record.hash) { + throw new Error(`Toolbar layout was edited locally: ${file}. No files removed; preserve/move this layout and its ${RECORD} before uninstalling.`); + } + const status = JSON.parse(await command(appHome, ["status"])); + const active = status.layout === record.layout; + if (active) { + const original = await readRegular(appHome, layoutFile(appHome, record.source)); + if (original === null) throw new Error(`Original layout ${record.source} is missing. Select another layout before uninstalling.`); + JSON.parse(original); + } + return { appHome, record, file }; +} + +export async function removeToolbar(plan) { + if (!plan) return; + const { appHome, record, file } = plan; + let current = await readRegular(appHome, file); + if (current !== null && sha256(current) !== record.hash) throw new Error(`Toolbar layout changed during uninstall: ${file}`); + // The person can switch layouts while preflight is running. Never trust its + // earlier active-layout snapshot when deciding whether it is safe to delete. + const status = JSON.parse(await command(appHome, ["status"])); + if (status.layout === record.layout) { + const original = await readRegular(appHome, layoutFile(appHome, record.source)); + if (original === null) throw new Error(`Original layout ${record.source} is missing. Select another layout before uninstalling.`); + JSON.parse(original); + await command(appHome, ["do", "layout.switch", JSON.stringify({ name: record.source })]); + const afterSwitch = JSON.parse(await command(appHome, ["status"])); + if (afterSwitch.layout !== record.source) throw new Error(`Herdr did not leave ${record.layout}; no files removed.`); + } + // Commands yield to the live app; preserve any edits made during that wait. + current = await readRegular(appHome, file); + if (current !== null && sha256(current) !== record.hash) throw new Error(`Toolbar layout changed during uninstall: ${file}`); + if (current !== null) await fs.unlink(file); + await fs.unlink(path.join(dataDir(appHome), RECORD)); + console.log(`Removed owned toolbar layout ${record.layout}; original layout ${record.source} is unchanged.`); +} diff --git a/herdr-insights/scripts/uninstall.mjs b/herdr-insights/scripts/uninstall.mjs index c6e93ac..369a23c 100644 --- a/herdr-insights/scripts/uninstall.mjs +++ b/herdr-insights/scripts/uninstall.mjs @@ -2,6 +2,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { + MOD_NAMES, assertNoSymlinkComponents, isInside, lstatOrNull, @@ -12,8 +13,11 @@ import { resolveAppHome, sha256, writeManifest, + withInstallerLock, } from "./lib.mjs"; +import { prepareToolbarRemoval, removeToolbar } from "./toolbar.mjs"; + function usage() { return `Insights safe uninstaller @@ -25,9 +29,10 @@ Options: --app-home Herdr app home (default: $HERDR_APP_HOME or ~/.config/herdr-app) -h, --help Show this help -Only files recorded in data/insights/install-manifest.json are removed, and only -when their content still matches. Edited files, unowned files, layouts, keys and -data/insights/insights.json are left alone. The installer never uses the network. +Only files recorded in data/insights/*install-manifest.json are removed, and only +when their content still matches. The owned personal toolbar layout is removed +only if unchanged; Herdr returns to the original layout first. Edited/unowned files, +shipped layouts, keys and data/insights/insights.json are left alone. `; } @@ -85,15 +90,10 @@ async function pruneOwnedDirectories(directories) { } } -async function uninstall(options) { - const appHome = await resolveAppHome(options.appHome, { create: false }); - if (!appHome) { - console.log("Insights is not installed by this package; nothing to remove."); - return; - } - const targetDirectory = modDir(appHome); - const targetManifest = manifestPath(appHome); - const manifest = await readManifest(appHome); +async function prepareUninstall(appHome, modName) { + const targetDirectory = modDir(appHome, modName); + const targetManifest = manifestPath(appHome, modName); + const manifest = await readManifest(appHome, modName); if (!manifest) { const target = await lstatOrNull(targetDirectory); @@ -102,8 +102,7 @@ async function uninstall(options) { `No install manifest at ${targetManifest}; refusing to remove unowned files in ${targetDirectory}.`, ); } - console.log("Insights is not installed by this package; nothing to remove."); - return; + return null; } const targetStat = await lstatOrNull(targetDirectory); @@ -114,13 +113,18 @@ async function uninstall(options) { throw new Error(`Target mod path is not a directory: ${targetDirectory}`); } + return { appHome, targetDirectory, targetManifest, manifest, targetStat, modName }; +} + +async function applyUninstall(plan) { + const { appHome, targetDirectory, targetManifest, manifest, targetStat, modName } = plan; const removed = []; const kept = []; const ownedParents = []; const remaining = Object.create(null); - for (const [relative, oldHash] of Object.entries(manifest.files)) { - if (!targetStat) break; + const entries = targetStat ? Object.entries(manifest.files) : []; + for (const [relative, oldHash] of entries) { const target = localPath(targetDirectory, relative); try { @@ -157,7 +161,7 @@ async function uninstall(options) { const remainingEntries = Object.entries(remaining); if (remainingEntries.length > 0) { - await writeManifest(appHome, Object.fromEntries(remainingEntries)); + await writeManifest(appHome, Object.fromEntries(remainingEntries), modName); } else { const currentManifest = await lstatOrNull(targetManifest); if (currentManifest) { @@ -191,7 +195,23 @@ async function main() { } try { - await uninstall(options); + const appHome = await resolveAppHome(options.appHome, { create: false }); + await withInstallerLock(appHome, async () => { + // Validate ownership, manifests and target types before deleting either mod. + const plans = []; + if (appHome) { + for (const modName of MOD_NAMES) { + const plan = await prepareUninstall(appHome, modName); + if (plan) plans.push(plan); + } + } + const toolbar = await prepareToolbarRemoval(appHome); + await removeToolbar(toolbar); + for (const plan of plans) await applyUninstall(plan); + if (plans.length === 0 && !toolbar) { + console.log("Insights is not installed by this package; nothing to remove."); + } + }); return 0; } catch (error) { console.error(`Error: ${error.message}`); diff --git a/scripts/herdr-insights/check.mjs b/scripts/herdr-insights/check.mjs index 47c01a1..c097417 100644 --- a/scripts/herdr-insights/check.mjs +++ b/scripts/herdr-insights/check.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import { MIN_NODE_VERSION, + MOD_NAMES, PACKAGE_ROOT, assertSupportedNodeVersion, collectSourceFiles, @@ -123,6 +124,7 @@ async function checkFiles() { "mods/insights/mod.json", "scripts/install.mjs", "scripts/lib.mjs", + "scripts/toolbar.mjs", "scripts/uninstall.mjs", ]) { const stat = await lstatOrNull(path.join(PACKAGE_ROOT, relative)); @@ -200,18 +202,18 @@ async function checkDistribution() { notes.push(...result.notes); } -async function checkSourceIfPresent() { - const sourceMod = path.join(PACKAGE_ROOT, "mods", "insights"); +async function checkSourceIfPresent(modName) { + const sourceMod = path.join(PACKAGE_ROOT, "mods", modName); const stat = await lstatOrNull(sourceMod); if (!stat) { - fail("mods/insights is required for the Herdr distribution"); + fail(`mods/${modName} is required for the Herdr distribution`); return; } try { - const source = await collectSourceFiles(PACKAGE_ROOT); - notes.push(`mods/insights source tree validates (${source.files.length} files)`); + const source = await collectSourceFiles(PACKAGE_ROOT, modName); + notes.push(`mods/${modName} source tree validates (${source.files.length} files)`); } catch (error) { - fail(`mods/insights source tree is invalid: ${error.message}`); + fail(`mods/${modName} source tree is invalid: ${error.message}`); } } @@ -230,7 +232,7 @@ await checkDistribution(); await checkDocs(); await checkScriptsAndTests(); await checkTestInventory(); -await checkSourceIfPresent(); +for (const modName of MOD_NAMES) await checkSourceIfPresent(modName); for (const note of notes) console.log(`Note: ${note}`); if (errors.length > 0) { diff --git a/scripts/herdr-insights/distribution.mjs b/scripts/herdr-insights/distribution.mjs index 269fbe1..d150530 100644 --- a/scripts/herdr-insights/distribution.mjs +++ b/scripts/herdr-insights/distribution.mjs @@ -24,7 +24,7 @@ export async function inspectDistribution(packageRoot) { } try { - const allowed = new Set(["install.mjs", "lib.mjs", "uninstall.mjs"]); + const allowed = new Set(["install.mjs", "lib.mjs", "toolbar.mjs", "uninstall.mjs"]); for (const name of await fs.readdir(path.join(packageRoot, "scripts"))) { if (!allowed.has(name)) errors.push(`Developer tooling must stay outside herdr-insights/scripts/: ${name}`); } diff --git a/tests/herdr-insights/analytics-ui.test.mjs b/tests/herdr-insights/analytics-ui.test.mjs index 9df70b3..01161bd 100644 --- a/tests/herdr-insights/analytics-ui.test.mjs +++ b/tests/herdr-insights/analytics-ui.test.mjs @@ -21,6 +21,10 @@ function fakeNativeElement() { w(value) { node.style.width = value; return element; }, whitespace_nowrap() { node.style.whitespace = "nowrap"; return element; }, items_start() { node.style.alignItems = "start"; return element; }, + flex_wrap() { node.style.flexWrap = "wrap"; return element; }, + min_w(value) { node.style.minWidth = value; return element; }, + flex_basis(value) { node.style.flexBasis = value; return element; }, + flex_grow(value) { node.style.flexGrow = value; return element; }, }; element = new Proxy(node, { get(target, property) { @@ -299,7 +303,7 @@ test("native mod keeps timers and shell use inside the Herdr contract", () => { assert.match(mainSource, /configure/); assert.match(mainSource, /--config/); assert.match(mainSource, /on_size_change/); - assert.match(mainSource, /overflow_x_scrollbar/); + assert.match(mainSource, /overflow_hidden/); assert.match(mainSource, /nextCommand/); assert.match(mainSource, /name === "refresh"/); assert.match(mainSource, /name === "settings"/); @@ -467,7 +471,7 @@ test("heatmap month labels keep their spans while partial labels cannot wrap", ( null, ); const metrics = heatmapMetrics(undefined, heatmap.weeks.length); - const grid = card.childNodes[1].childNodes[0].childNodes[0]; + const grid = card.childNodes[1].childNodes[0]; const monthNodes = grid.childNodes[0].childNodes.slice(1); assert.equal(monthNodes.length, heatmap.months.length); @@ -483,14 +487,19 @@ test("heatmap month labels keep their spans while partial labels cannot wrap", ( }); }); -test("heatmap cells grow and center on wide views", () => { +test("heatmap grows on wide views and prefers the newest weeks when narrow", () => { const wide = heatmapMetrics(1600, 53); const narrow = heatmapMetrics(480, 53); assert.ok(wide.cell > narrow.cell, "wider views get larger cells"); assert.ok(wide.cell <= 20, "cells stay capped"); assert.equal(wide.centered, true); + assert.equal(narrow.centered, false); assert.equal(wide.weekWidth, wide.cell + 2); - assert.match(mainSource, /metrics\.centered, \(el\) => el\.justify_center\(\)/); + assert.match(mainSource, /\.when\(metrics\.centered, \(el\) => el\.justify_center\(\)\)/); + assert.match(mainSource, /\.when\(!metrics\.centered, \(el\) => el\.justify_end\(\)\)/, + "an overflowing grid is end-aligned so recent weeks stay visible"); + assert.match(mainSource, /h_flex\(\)\.w_full\(\)\.overflow_hidden\(\)[\s\S]{0,200}metrics\.centered/, + "the grid is clipped, not scrolled, so end alignment applies"); }); test("dashboard is divided into token and model usage sections", () => { @@ -585,6 +594,39 @@ test("model usage leaderboard is models-only with ranked change rows", () => { assert.deepEqual(valueColumn.childNodes[1].childNodes, ["—"], "no previous period shows a dash"); }); +test("model usage wraps into columns instead of switching on a pane width", () => { + const instance = blankInstance(); + const c = ui.tokens({ + background: "#101010", + surface: "#181818", + border: "#333333", + foreground: "#f5f5f5", + muted: "#9a9a9a", + dim: "#707070", + accent: "#5aa7ff", + }); + const rows = Array.from({ length: 10 }, (_, index) => ({ + day: "2024-01-10", + model: `model-${index}`, + provider: "p-one", + tokens: { input: 100 - index, output: 10 }, + })); + const leaders = analytics.leaderboard(rows, null, { groupBy: "model", includeCache: true }); + + const grid = instance.leaderboardCard(c, leaders, { label: "All time" }).childNodes[1]; + assert.equal(grid.style.flexWrap, "wrap", "columns wrap instead of using a width breakpoint"); + assert.deepEqual(grid.childNodes.map((column) => column.childNodes.length), [5, 5]); + for (const column of grid.childNodes) { + assert.equal(column.style.flexBasis, 300, "preferred width controls wrapping"); + assert.equal(column.style.flexGrow, 1, "columns share remaining space"); + assert.equal(column.style.minWidth, 0, "a column can shrink below its basis in narrow split panes"); + } + + const single = analytics.leaderboard(rows.slice(0, 1), null, { groupBy: "model", includeCache: true }); + const singleGrid = instance.leaderboardCard(c, single, { label: "All time" }).childNodes[1]; + assert.equal(singleGrid.childNodes.length, 1, "an empty second column is not rendered"); +}); + test("loading state shows live scan progress and scan status lives in settings", () => { assert.doesNotMatch(mainSource, /Reading local session history|collector and presentation helper|Preparing the compact dashboard|Initializing the native source|bottomGrid/); assert.match(mainSource, /children\(\[preferences, sources, scan, json\]\)/); diff --git a/tests/herdr-insights/distribution.test.mjs b/tests/herdr-insights/distribution.test.mjs index 9c7d8ca..1c6b0b4 100644 --- a/tests/herdr-insights/distribution.test.mjs +++ b/tests/herdr-insights/distribution.test.mjs @@ -14,8 +14,10 @@ const read = (relative) => readFile(path.join(root, relative), "utf8"); test("Herdr distribution has the requested folder and display name, with no layouts or bindings", async () => { assert.equal(path.basename(root), "herdr-insights"); assert.deepEqual(JSON.parse(await read("mods/insights/mod.json")), { place: "none", title: "Insights" }); - const modEntries = (await readdir(path.join(root, "mods"))).filter((name) => name !== ".DS_Store"); - assert.deepEqual(modEntries, ["insights"]); + const modEntries = (await readdir(path.join(root, "mods"))).filter((name) => name !== ".DS_Store").sort(); + assert.deepEqual(modEntries, ["insights", "insights-launcher"]); + assert.deepEqual(JSON.parse(await read("mods/insights-launcher/mod.json")), + { place: "none", title: "Insights" }); await assert.rejects(lstat(path.join(root, "layouts")), { code: "ENOENT" }); await assert.rejects(lstat(path.join(root, "keys.json")), { code: "ENOENT" }); assert.match(await read("RECIPE.md"), /^# Insights\n/); @@ -79,7 +81,7 @@ test("Distribution inspection rejects keys.json and layouts without ignoring the test("shared distribution excludes developer tests, fixtures, and smoke tooling", async () => { await assert.rejects(lstat(path.join(root, "tests")), { code: "ENOENT" }); assert.deepEqual((await readdir(path.join(root, "scripts"))).sort(), - ["install.mjs", "lib.mjs", "uninstall.mjs"]); + ["install.mjs", "lib.mjs", "toolbar.mjs", "uninstall.mjs"]); assert.deepEqual((await inspectDistribution(root)).errors, []); }); @@ -104,7 +106,7 @@ test("agent installation entry point separates installation from rebuilding", as assert.match(install, /does not override its review requirements/); assert.match(install, /npm run install:local --prefix herdr-insights\n/); assert.match(install, /Do not run developer tests/); - assert.match(install, /Never\nlaunch a second Herdr instance/); + assert.match(install, /Never\s+launch a second Herdr instance/); assert.match(recipe, /Done when\*\* checks apply\nto building\/releasing, not routine installation/); }); diff --git a/tests/herdr-insights/install-fixtures.mjs b/tests/herdr-insights/install-fixtures.mjs new file mode 100644 index 0000000..39eadd0 --- /dev/null +++ b/tests/herdr-insights/install-fixtures.mjs @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const PACKAGE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../herdr-insights", +); +export const INSTALL_SCRIPT = path.join(PACKAGE_ROOT, "scripts", "install.mjs"); +export const UNINSTALL_SCRIPT = path.join(PACKAGE_ROOT, "scripts", "uninstall.mjs"); +export const RUNTIME_REL = "runtime.json"; + +export const DEFAULT_MOD_FILES = { + "analytics.js": "export const version = 1;\n", + "collector.mjs": "export const collector = true;\n", + "core/usage.mjs": "export const usage = true;\n", + "icons/insights.svg": '\n', + "main.js": "export default class Insights {}\n", + "mod.json": '{\n "place": "none"\n}\n', +}; + +export const TOOLBAR_LAYOUT = JSON.stringify({ id: "root", type: "column", children: [ + { id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs" } }, + { id: "main", type: "block", content: { tab: "active" } }, +] }) + "\n"; + +export const APP_FILES = { + "data/insights/insights.json": '{"version":1,"keep":"local"}\n', + "data/insights/local-note.txt": "local data\n", + "keys.json": '{"global":{"ctrl-shift-t":"tab.new"}}\n', + "layouts/main.json": TOOLBAR_LAYOUT, + "mods/other/main.js": "export default class Other {}\n", + "settings.json": '{"zoom":1}\n', +}; + +export async function temporaryRoot(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "herdr-insights-test-")); + t.after(async () => { + await fs.rm(root, { force: true, recursive: true }); + }); + return root; +} + +export async function createSource(root, files = DEFAULT_MOD_FILES, name = "reviewed source") { + const sourceRoot = path.join(root, name); + await writeTree(path.join(sourceRoot, "mods", "insights"), files); + await writeTree(sourceRoot, { + "README.md": "not part of the mod\n", + "keys.json": '{"global":{}}\n', + "layouts/other.json": '{"id":"other"}\n', + "tests/source-only.txt": "not installed\n", + }); + return sourceRoot; +} + +export async function createSourceWithLauncher(root) { + const sourceRoot = await createSource(root); + await fs.cp(path.join(PACKAGE_ROOT, "mods/insights-launcher"), + path.join(sourceRoot, "mods/insights-launcher"), { recursive: true }); + return sourceRoot; +} + +export async function createFakeRunner(root) { + const bin = path.join(root, "fake bin"); + const runner = path.join(bin, "herdr-app"); + const log = path.join(root, "fake herdr log.jsonl"); + await fs.mkdir(bin, { recursive: true }); + await fs.writeFile( + runner, + `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); +const log = process.env.FAKE_HERDR_LOG; +if (log) fs.appendFileSync(log, JSON.stringify({ appHome: process.env.HERDR_APP_HOME ?? null, args }) + "\\n"); + +if (args[0] === "check" && process.env.FAKE_HERDR_EDIT_TARGET) { + fs.appendFileSync(process.env.FAKE_HERDR_EDIT_TARGET, "edit during check\\n"); +} +if (args[0] === "check" && (process.env.FAKE_HERDR_FAIL === "check" || + (process.env.FAKE_HERDR_FAIL === "launcher-check" && args[1].endsWith("insights-launcher")))) { + console.error("fake herdr-app: check failed"); + process.exit(7); +} +if (process.env.FAKE_HERDR_FAIL === "open" && args[0] === "do") { + console.error("fake herdr-app: open failed"); + process.exit(8); +} +if (args[0] === "status") { + const entries = fs.readFileSync(log, "utf8").trim().split("\\n").map(line => JSON.parse(line)); + const last = entries.reverse().find(entry => entry.appHome === process.env.HERDR_APP_HOME && entry.args[0] === "do" && ["layout.clone", "layout.switch"].includes(entry.args[1]) && + !(process.env.FAKE_HERDR_FAIL === "ignored-switch" && entry.args[1] === "layout.switch")); + const chosen = last ? JSON.parse(last.args[2]) : null; + console.log(JSON.stringify({ layout: chosen?.as ?? chosen?.name ?? "main", overlays: [], errors: [] })); + process.exit(0); +} +if (args[0] === "do" && args[1] === "layout.switch" && process.env.FAKE_HERDR_FAIL === "edit-on-switch") { + fs.appendFileSync(path.join(process.env.HERDR_APP_HOME, "layouts/main-insights.json"), "local edit\\n"); +} +if (args[0] === "do" && args[1] === "layout.clone") { + const request = JSON.parse(args[2]); + const dir = path.join(process.env.HERDR_APP_HOME, "layouts"); + fs.copyFileSync(path.join(dir, request.name + ".json"), path.join(dir, request.as + ".json"), fs.constants.COPYFILE_EXCL); + const state = path.join(dir, ".state"); + fs.mkdirSync(state, { recursive: true }); + if (fs.existsSync(path.join(state, request.name + ".json"))) { + fs.copyFileSync(path.join(state, request.name + ".json"), path.join(state, request.as + ".json")); + } else fs.rmSync(path.join(state, request.as + ".json"), { force: true }); +} +console.log("fake herdr-app: " + args.join(" ")); +`, + { mode: 0o755 }, + ); + await fs.chmod(runner, 0o755); + return { bin, log, runner }; +} + +export function makeEnvironment(root, appHome, fake, options = {}) { + const env = { + ...process.env, + FAKE_HERDR_LOG: fake.log, + HERDR_APP_HOME: appHome, + HOME: path.join(root, "fake home"), + }; + delete env.FAKE_HERDR_FAIL; + if (options.fail) env.FAKE_HERDR_FAIL = options.fail; + + if (options.pathLookup) { + delete env.HERDR_APP_BIN; + env.PATH = `${path.dirname(fake.runner)}${path.delimiter}${process.env.PATH ?? ""}`; + } else { + env.HERDR_APP_BIN = fake.runner; + } + return env; +} + +export function runInstall({ appHome, fake, sourceRoot, extra = [], fail, pathLookup = false, env = {} }) { + const root = path.dirname(appHome); + const environment = makeEnvironment(root, appHome, fake, { fail, pathLookup }); + return runNode(INSTALL_SCRIPT, ["--source", sourceRoot, "--app-home", appHome, ...extra], { ...environment, ...env }); +} + +export function runUninstall({ appHome, fake, fail }) { + const environment = fake ? makeEnvironment(path.dirname(appHome), appHome, fake, { fail }) + : { ...process.env, HERDR_APP_HOME: appHome }; + return runNode(UNINSTALL_SCRIPT, ["--app-home", appHome], environment); +} + +function runNode(script, args, environment) { + return spawnSync(process.execPath, [script, ...args], { + cwd: PACKAGE_ROOT, + encoding: "utf8", + env: environment, + maxBuffer: 10 * 1024 * 1024, + shell: false, + timeout: 30_000, + }); +} + +export function assertSuccess(result) { + assert.equal( + result.status, + 0, + `expected success\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); +} + +export function assertFailure(result) { + assert.notEqual( + result.status, + 0, + `expected failure\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + assert.equal(result.error, undefined, String(result.error)); +} + +export async function readLog(fake) { + let text; + try { + text = await fs.readFile(fake.log, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + return text + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +export async function assertStagedChecks(log, names, appHome) { + const checks = log.filter((entry) => entry.args[0] === "check"); + assert.deepEqual(checks.map((entry) => path.basename(entry.args[1])), names); + for (const entry of checks) { + assert.notEqual(entry.appHome, await fs.realpath(appHome), "checks must not use the live app home"); + assert.deepEqual(entry.args, ["check", path.join(entry.appHome, "mods", path.basename(entry.args[1]))]); + assert.equal(await exists(entry.appHome), false, "temporary checks must be cleaned up"); + } + return checks.map((entry) => entry.args); +} + +export async function readJson(file) { + return JSON.parse(await fs.readFile(file, "utf8")); +} + +export async function snapshotTree(root) { + return Promise.all((await listFiles(root)).map(async (relative) => + [relative, await fs.readFile(path.join(root, relative), "utf8")])); +} + +export async function writeTree(base, files) { + for (const [relative, content] of Object.entries(files)) { + const target = path.join(base, ...relative.split("/")); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, content); + } +} + +export async function listFiles(base) { + const files = []; + async function walk(directory, relative) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const childRelative = relative ? `${relative}/${entry.name}` : entry.name; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(absolute, childRelative); + } else if (entry.isFile()) { + files.push(childRelative); + } + } + } + await walk(base, ""); + return files.sort(); +} + +export async function exists(target) { + try { + await fs.lstat(target); + return true; + } catch (error) { + if (error.code === "ENOENT") return false; + throw error; + } +} + +export function hash(content) { + return createHash("sha256").update(content).digest("hex"); +} diff --git a/tests/herdr-insights/install-safety.test.mjs b/tests/herdr-insights/install-safety.test.mjs new file mode 100644 index 0000000..fc79a64 --- /dev/null +++ b/tests/herdr-insights/install-safety.test.mjs @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { withInstallerLock } from "../../herdr-insights/scripts/lib.mjs"; +import { + applyToolbar, prepareToolbar, prepareToolbarRemoval, removeToolbar, +} from "../../herdr-insights/scripts/toolbar.mjs"; +import { + APP_FILES, assertFailure, assertSuccess, createFakeRunner, createSourceWithLauncher, + exists, makeEnvironment, readLog, runInstall, runUninstall, snapshotTree, + temporaryRoot, writeTree, +} from "./install-fixtures.mjs"; + +function useFakeHerdr(t, fake) { + for (const [key, value] of Object.entries({ HERDR_APP_BIN: fake.runner, FAKE_HERDR_LOG: fake.log })) { + const previous = process.env[key]; + process.env[key] = value; + t.after(() => { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + }); + } +} + +function switchLayout(root, appHome, fake, name) { + assertSuccess(spawnSync(fake.runner, ["do", "layout.switch", JSON.stringify({ name })], { + env: makeEnvironment(root, appHome, fake), encoding: "utf8", timeout: 30_000, + })); +} + +test("the previously documented --open flag opens exactly one tab only after successful installation", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, APP_FILES); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot, extra: ["--open"] })); + let log = await readLog(fake); + assert.deepEqual(log.at(-1).args, ["do", "tab.new", '{"name":"Insights","mod":"insights"}']); + assert.equal(log.filter((entry) => entry.args[1] === "tab.new").length, 1); + const result = runInstall({ appHome, fake, sourceRoot, extra: ["--open"], fail: "launcher-check" }); + assertFailure(result); + log = await readLog(fake); + assert.equal(log.filter((entry) => entry.args[1] === "tab.new").length, 1, "a failed check must never open a tab"); + const failedOpen = runInstall({ appHome, fake, sourceRoot, extra: ["--open"], fail: "open" }); + assertFailure(failedOpen); + assert.match(failedOpen.stderr, /Opening Insights failed/); + assert.equal(await exists(path.join(appHome, "mods/insights/main.js")), true); + assert.equal(await exists(path.join(appHome, ".insights-install.lock")), false); +}); + +test("a failed check of either mod preserves an existing installation byte-for-byte", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, APP_FILES); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + const before = await snapshotTree(appHome); + await fs.writeFile(path.join(sourceRoot, "mods/insights/main.js"), "new dashboard\n"); + await fs.writeFile(path.join(sourceRoot, "mods/insights-launcher/main.js"), "new launcher\n"); + for (const fail of ["check", "launcher-check"]) { + const result = runInstall({ appHome, fake, sourceRoot, fail }); + assertFailure(result); + assert.match(result.stderr, /No installed mod files were changed/); + assert.deepEqual(await snapshotTree(appHome), before); + assert.equal(await exists(path.join(appHome, ".insights-install.lock")), false); + const checks = (await readLog(fake)).filter((entry) => entry.args[0] === "check"); + for (const entry of checks) assert.equal(await exists(entry.appHome), false, "staging is cleaned on failure"); + } +}); + +test("edits made during native checks are refused before either live mod is replaced", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, APP_FILES); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + const dashboard = path.join(appHome, "mods/insights/main.js"); + const launcher = path.join(appHome, "mods/insights-launcher/main.js"); + const before = await fs.readFile(dashboard, "utf8"); + await fs.writeFile(path.join(sourceRoot, "mods/insights/main.js"), "reviewed dashboard update\n"); + const result = runInstall({ appHome, fake, sourceRoot, env: { FAKE_HERDR_EDIT_TARGET: launcher } }); + assertFailure(result); + assert.match(result.stderr, /Owned file was edited locally/); + assert.equal(await fs.readFile(dashboard, "utf8"), before); + assert.match(await fs.readFile(launcher, "utf8"), /edit during check/); +}); + +test("fresh launcher check failures leave neither live mod installed", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, APP_FILES); + const before = await snapshotTree(appHome); + const fake = await createFakeRunner(root); + assertFailure(runInstall({ appHome, fake, sourceRoot, fail: "launcher-check" })); + assert.deepEqual(await snapshotTree(appHome), before); + assert.equal(await exists(path.join(appHome, "mods/insights")), false); + assert.equal(await exists(path.join(appHome, "mods/insights-launcher")), false); +}); + +test("concurrent install and uninstall fail closed while another operation holds the lock", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, APP_FILES); + const fake = await createFakeRunner(root); + const before = await snapshotTree(appHome); + await withInstallerLock(appHome, async () => { + for (const result of [runInstall({ appHome, fake, sourceRoot, extra: ["--force"] }), runUninstall({ appHome, fake })]) { + assertFailure(result); + assert.match(result.stderr, /install\/uninstall is locked/); + } + assert.deepEqual(await snapshotTree(appHome), before); + assert.deepEqual(await readLog(fake), []); + }); + assert.equal(await exists(path.join(appHome, ".insights-install.lock")), false); + await assert.rejects(withInstallerLock(appHome, async () => { throw new Error("failed operation"); }), /failed operation/); + assert.equal(await exists(path.join(appHome, ".insights-install.lock")), false); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); +}); + +test("toolbar revalidates destination state and source links after preflight", async (t) => { + const root = await temporaryRoot(t); + const fake = await createFakeRunner(root); + useFakeHerdr(t, fake); + for (const kind of ["destination-state", "destination-link", "source-link"]) { + const appHome = path.join(root, kind); + await writeTree(appHome, APP_FILES); + const plan = await prepareToolbar(appHome); + const state = path.join(appHome, "layouts/.state", kind === "source-link" ? "main.json" : "main-insights.json"); + await fs.mkdir(path.dirname(state), { recursive: true }); + const outside = path.join(root, `${kind}.json`); + await fs.writeFile(outside, "preserved state\n"); + if (kind === "destination-state") await fs.writeFile(state, "new click state\n"); + else await fs.symlink(outside, state); + await assert.rejects(applyToolbar(plan), /destination appeared|symbolic link/); + assert.equal(await fs.readFile(outside, "utf8"), "preserved state\n"); + assert.equal(await exists(path.join(appHome, "layouts/main-insights.json")), false); + } + assert.equal((await readLog(fake)).filter((entry) => entry.args[1] === "layout.clone").length, 0); +}); + +test("uninstall rechecks the active layout instead of deleting a newly selected toolbar", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, APP_FILES); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + useFakeHerdr(t, fake); + switchLayout(root, appHome, fake, "main"); + const plan = await prepareToolbarRemoval(appHome); + switchLayout(root, appHome, fake, "main-insights"); + await removeToolbar(plan); + const log = await readLog(fake); + assert.deepEqual(log.at(-2).args, ["do", "layout.switch", '{"name":"main"}']); + assert.deepEqual(log.at(-1).args, ["status"]); + assert.equal(await exists(path.join(appHome, "layouts/main-insights.json")), false); +}); + +test("uninstall preserves a toolbar edited while the layout-switch command is running", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, APP_FILES); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + const result = runUninstall({ appHome, fake, fail: "edit-on-switch" }); + assertFailure(result); + assert.match(result.stderr, /Toolbar layout changed during uninstall/); + assert.match(await fs.readFile(path.join(appHome, "layouts/main-insights.json"), "utf8"), /local edit/); + for (const relative of ["mods/insights/main.js", "mods/insights-launcher/main.js", "data/insights/toolbar-install.json"]) { + assert.equal(await exists(path.join(appHome, relative)), true); + } +}); diff --git a/tests/herdr-insights/install.test.mjs b/tests/herdr-insights/install.test.mjs index ff1db0a..ecd84d9 100644 --- a/tests/herdr-insights/install.test.mjs +++ b/tests/herdr-insights/install.test.mjs @@ -1,32 +1,37 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; -import os from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { isNodeVersionSupported } from "../../herdr-insights/scripts/lib.mjs"; +import { + APP_FILES, + DEFAULT_MOD_FILES, + INSTALL_SCRIPT, + PACKAGE_ROOT, + RUNTIME_REL, + UNINSTALL_SCRIPT, + assertFailure, + assertStagedChecks, + assertSuccess, + createFakeRunner, + createSource, + exists, + hash, + listFiles, + makeEnvironment, + readJson, + readLog, + runInstall, + runUninstall, + snapshotTree, + temporaryRoot, + writeTree, +} from "./install-fixtures.mjs"; -const PACKAGE_ROOT = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../../herdr-insights", -); -const INSTALL_SCRIPT = path.join(PACKAGE_ROOT, "scripts", "install.mjs"); -const UNINSTALL_SCRIPT = path.join(PACKAGE_ROOT, "scripts", "uninstall.mjs"); const CHECK_SCRIPT = fileURLToPath(new URL("../../scripts/herdr-insights/check.mjs", import.meta.url)); -const OPEN_JSON = '{"name":"Insights","mod":"insights"}'; -const RUNTIME_REL = "runtime.json"; - -const DEFAULT_MOD_FILES = { - "analytics.js": "export const version = 1;\n", - "collector.mjs": "export const collector = true;\n", - "core/usage.mjs": "export const usage = true;\n", - "icons/insights.svg": '\n', - "main.js": "export default class Insights {}\n", - "mod.json": '{\n "place": "none"\n}\n', -}; function runtimeContent(nodePath = process.execPath) { return `${JSON.stringify({ node: nodePath })}\n`; @@ -36,15 +41,6 @@ function ownedFiles() { return [...Object.keys(DEFAULT_MOD_FILES), RUNTIME_REL].sort(); } -const APP_FILES = { - "data/insights/insights.json": '{"version":1,"keep":"local"}\n', - "data/insights/local-note.txt": "local data\n", - "keys.json": '{"global":{"ctrl-shift-t":"tab.new"}}\n', - "layouts/main.json": '{"id":"root","type":"row","children":[]}\n', - "mods/other/main.js": "export default class Other {}\n", - "settings.json": '{"zoom":1}\n', -}; - test("manifest keys round-trip safely, including __proto__", async (t) => { const root = await temporaryRoot(t); const sourceRoot = await createSource(root, { ...DEFAULT_MOD_FILES, ["__proto__"]: "owned\n" }); @@ -66,15 +62,13 @@ test("corrupt manifests fail closed with recovery guidance and preserve data", a assertSuccess(runInstall({ appHome, fake, sourceRoot })); const manifest = path.join(appHome, "data/insights/install-manifest.json"); await fs.writeFile(manifest, "{broken"); - const snapshot = async () => Promise.all((await listFiles(appHome)).map(async (relative) => - [relative, await fs.readFile(path.join(appHome, relative), "utf8")])); - const before = await snapshot(); + const before = await snapshotTree(appHome); for (const result of [runInstall({ appHome, fake, sourceRoot }), runUninstall({ appHome })]) { assert.notEqual(result.status, 0); assert.match(result.stderr, /preserve or move aside both/); assert.match(result.stderr, /insights.json.*in place/); } - assert.deepEqual(await snapshot(), before); + assert.deepEqual(await snapshotTree(appHome), before); }); test("reinstall retains ownership proof for edited stale files", async (t) => { @@ -146,11 +140,7 @@ test("fresh install copies only owned mod files and preserves local app state", const log = await readLog(fake); assert.equal(log.length, 1); - assert.deepEqual(log[0].args, [ - "check", - path.join(await fs.realpath(appHome), "mods", "insights"), - ]); - assert.equal(log[0].appHome, await fs.realpath(appHome)); + await assertStagedChecks(log, ["insights"], appHome); const fakeHome = path.join(root, "fake home"); if (await exists(fakeHome)) { @@ -238,7 +228,7 @@ test("runtime.json is generated from the installer Node, replaces a source copy, assert.equal(await exists(path.join(symlinkApp, "mods", "insights")), false); }); -test("--open uses argv-safe executable arguments and runs only after a passing check", async (t) => { +test("install runs only herdr-app check and never opens a tab", async (t) => { const root = await temporaryRoot(t); const sourceRoot = await createSource(root, DEFAULT_MOD_FILES, "reviewed source's tree"); const appHome = path.join(root, "herdr app's home (dev)"); @@ -249,19 +239,13 @@ test("--open uses argv-safe executable arguments and runs only after a passing c fake, pathLookup: true, sourceRoot, - extra: ["--open"], }); assertSuccess(result); const log = await readLog(fake); - assert.equal(log.length, 2); - assert.deepEqual(log[0].args, [ - "check", - path.join(await fs.realpath(appHome), "mods", "insights"), - ]); - assert.equal(log[0].appHome, await fs.realpath(appHome)); - assert.deepEqual(log[1].args, ["do", "tab.new", OPEN_JSON]); - assert.match(result.stdout, /Opened the Insights tab/); + assert.equal(log.length, 1); + await assertStagedChecks(log, ["insights"], appHome); + assert.doesNotMatch(result.stdout, /Opened the Insights tab/); }); test("reinstall is idempotent, updates reviewed files, removes stale ones, and keeps local extras", async (t) => { @@ -472,23 +456,23 @@ test("a failed herdr-app check exits nonzero, does not open, and the install can appHome, fake, sourceRoot, - extra: ["--open"], fail: "check", }); assertFailure(failed); assert.match(failed.stderr, /fake herdr-app: check failed/); assert.match(failed.stderr, /herdr-app check failed/); - assert.equal(await exists(path.join(appHome, "mods", "insights", "main.js")), true); + assert.equal(await exists(path.join(appHome, "mods", "insights", "main.js")), false); + assert.deepEqual(await snapshotTree(appHome), Object.entries(APP_FILES).sort(([a], [b]) => a.localeCompare(b))); let log = await readLog(fake); assert.equal(log.length, 1); assert.equal(log[0].args[0], "check"); - const rerun = runInstall({ appHome, fake, sourceRoot, extra: ["--open"] }); + const rerun = runInstall({ appHome, fake, sourceRoot }); assertSuccess(rerun); log = await readLog(fake); - assert.equal(log.length, 3); + assert.equal(log.length, 2); assert.equal(log[1].args[0], "check"); - assert.deepEqual(log[2].args, ["do", "tab.new", OPEN_JSON]); + assert.equal(log.filter((entry) => entry.args[0] === "do").length, 0); assert.equal( await fs.readFile(path.join(appHome, "data", "insights", "insights.json"), "utf8"), APP_FILES["data/insights/insights.json"], @@ -743,28 +727,6 @@ test("uninstaller remains usable on an older Node runtime", async (t) => { ); }); -test("standalone distribution installs with no repo tooling or window commands", async (t) => { - const root = await temporaryRoot(t); - const distribution = path.join(root, "herdr-insights"); - await fs.cp(PACKAGE_ROOT, distribution, { recursive: true }); - const appHome = path.join(root, "app"); - const fake = await createFakeRunner(root); - const result = spawnSync(process.execPath, [path.join(distribution, "scripts/install.mjs")], { - cwd: distribution, - env: makeEnvironment(root, appHome, fake), - encoding: "utf8", - shell: false, - timeout: 30_000, - }); - assertSuccess(result); - assert.match(result.stdout, /herdr-app check passed/); - assert.deepEqual((await readLog(fake)).map((entry) => entry.args), - [["check", path.join(await fs.realpath(appHome), "mods/insights")]]); - await assert.rejects(fs.lstat(path.join(appHome, "data/insights/insights.json")), { code: "ENOENT" }); - assert.deepEqual((await fs.readdir(root)).sort(), - ["app", "fake bin", "fake herdr log.jsonl", "herdr-insights"]); -}); - test("async smoke test refuses to open a window without explicit opt-in", async (t) => { const root = await temporaryRoot(t); const script = fileURLToPath(new URL("./native-async-check.mjs", import.meta.url)); @@ -832,169 +794,3 @@ function findUnsupportedNode() { } return null; } - -async function temporaryRoot(t) { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "herdr-insights-test-")); - t.after(async () => { - await fs.rm(root, { force: true, recursive: true }); - }); - return root; -} - -async function createSource(root, files = DEFAULT_MOD_FILES, name = "reviewed source") { - const sourceRoot = path.join(root, name); - await writeTree(path.join(sourceRoot, "mods", "insights"), files); - await writeTree(sourceRoot, { - "README.md": "not part of the mod\n", - "keys.json": '{"global":{}}\n', - "layouts/other.json": '{"id":"other"}\n', - "tests/source-only.txt": "not installed\n", - }); - return sourceRoot; -} - -async function createFakeRunner(root) { - const bin = path.join(root, "fake bin"); - const runner = path.join(bin, "herdr-app"); - const log = path.join(root, "fake herdr log.jsonl"); - await fs.mkdir(bin, { recursive: true }); - await fs.writeFile( - runner, - `#!/usr/bin/env node -import fs from "node:fs"; - -const args = process.argv.slice(2); -const log = process.env.FAKE_HERDR_LOG; -if (log) fs.appendFileSync(log, JSON.stringify({ appHome: process.env.HERDR_APP_HOME ?? null, args }) + "\\n"); - -if (process.env.FAKE_HERDR_FAIL === "check" && args[0] === "check") { - console.error("fake herdr-app: check failed"); - process.exit(7); -} -if (process.env.FAKE_HERDR_FAIL === "open" && args[0] === "do") { - console.error("fake herdr-app: open failed"); - process.exit(8); -} -console.log("fake herdr-app: " + args.join(" ")); -`, - { mode: 0o755 }, - ); - await fs.chmod(runner, 0o755); - return { bin, log, runner }; -} - -function makeEnvironment(root, appHome, fake, options = {}) { - const env = { - ...process.env, - FAKE_HERDR_LOG: fake.log, - HERDR_APP_HOME: appHome, - HOME: path.join(root, "fake home"), - }; - delete env.FAKE_HERDR_FAIL; - if (options.fail) env.FAKE_HERDR_FAIL = options.fail; - - if (options.pathLookup) { - delete env.HERDR_APP_BIN; - env.PATH = `${path.dirname(fake.runner)}${path.delimiter}${process.env.PATH ?? ""}`; - } else { - env.HERDR_APP_BIN = fake.runner; - } - return env; -} - -function runInstall({ appHome, fake, sourceRoot, extra = [], fail, pathLookup = false }) { - const root = path.dirname(appHome); - const environment = makeEnvironment(root, appHome, fake, { fail, pathLookup }); - return runNode(INSTALL_SCRIPT, ["--source", sourceRoot, "--app-home", appHome, ...extra], environment); -} - -function runUninstall({ appHome }) { - const environment = { ...process.env, HERDR_APP_HOME: appHome }; - return runNode(UNINSTALL_SCRIPT, ["--app-home", appHome], environment); -} - -function runNode(script, args, environment) { - return spawnSync(process.execPath, [script, ...args], { - cwd: PACKAGE_ROOT, - encoding: "utf8", - env: environment, - maxBuffer: 10 * 1024 * 1024, - shell: false, - timeout: 30_000, - }); -} - -function assertSuccess(result) { - assert.equal( - result.status, - 0, - `expected success\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ); -} - -function assertFailure(result) { - assert.notEqual( - result.status, - 0, - `expected failure\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ); - assert.equal(result.error, undefined, String(result.error)); -} - -async function readLog(fake) { - let text; - try { - text = await fs.readFile(fake.log, "utf8"); - } catch (error) { - if (error.code === "ENOENT") return []; - throw error; - } - return text - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line)); -} - -async function readJson(file) { - return JSON.parse(await fs.readFile(file, "utf8")); -} - -async function writeTree(base, files) { - for (const [relative, content] of Object.entries(files)) { - const target = path.join(base, ...relative.split("/")); - await fs.mkdir(path.dirname(target), { recursive: true }); - await fs.writeFile(target, content); - } -} - -async function listFiles(base) { - const files = []; - async function walk(directory, relative) { - const entries = await fs.readdir(directory, { withFileTypes: true }); - for (const entry of entries) { - const childRelative = relative ? `${relative}/${entry.name}` : entry.name; - const absolute = path.join(directory, entry.name); - if (entry.isDirectory()) { - await walk(absolute, childRelative); - } else if (entry.isFile()) { - files.push(childRelative); - } - } - } - await walk(base, ""); - return files.sort(); -} - -async function exists(target) { - try { - await fs.lstat(target); - return true; - } catch (error) { - if (error.code === "ENOENT") return false; - throw error; - } -} - -function hash(content) { - return createHash("sha256").update(content).digest("hex"); -} diff --git a/tests/herdr-insights/launcher.test.mjs b/tests/herdr-insights/launcher.test.mjs new file mode 100644 index 0000000..a1a346b --- /dev/null +++ b/tests/herdr-insights/launcher.test.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { collectSourceFiles, PACKAGE_ROOT } from "../../herdr-insights/scripts/lib.mjs"; +import { withInsightsToolbar } from "../../herdr-insights/scripts/toolbar.mjs"; + +const source = await readFile(new URL("../../herdr-insights/mods/insights-launcher/main.js", import.meta.url), "utf8"); +function element() { + const node = { calls: [], children: [] }; + let proxy; + proxy = new Proxy(node, { get(target, property) { + if (property in target) return target[property]; + return (...args) => { + node.calls.push([property, args]); + if (property === "child") node.children.push(args[0]); + return proxy; + }; + } }); + return proxy; +} +const state = { commands: [], panes: [], spaces: [], theme: { chrome: "#111111", border: "#222222", muted: "#888888" } }; +globalThis.__insightsLauncherTest = state; +const transformed = source + .replace(/^import .* from "gpui-kit";$/m, "class View {}\nconst svg = () => globalThis.__insightsLauncherTest.element();") + .replace(/^import .* from "gpui-base";$/m, "const h_flex = () => globalThis.__insightsLauncherTest.element();") + .replace(/^import .* from "herdr";$/m, `const theme = () => globalThis.__insightsLauncherTest.theme; +const run = (...args) => globalThis.__insightsLauncherTest.commands.push(args); +const spaces = () => globalThis.__insightsLauncherTest.spaces; +const panes = () => globalThis.__insightsLauncherTest.panes;`); +state.element = element; +const { default: Launcher } = await import(`data:text/javascript;base64,${Buffer.from(transformed).toString("base64")}`); + +function buttonOf(launcher) { + return launcher.render().children[0]; +} + +function click(launcher) { + buttonOf(launcher).calls.find(([name]) => name === "on_click")[1][0](); +} + +test("toolbar button matches native panel controls and render has no side effects", () => { + state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; + state.panes = []; + state.commands.length = 0; + const launcher = new Launcher(); + const button = buttonOf(launcher); + assert.equal(button.children[1], "Insights"); + for (const [method, value] of [["h", 26], ["px", 8], ["gap", 5], ["rounded", 6], ["text_size", 12]]) { + assert.ok(button.calls.some(([name, args]) => name === method && args[0] === value), `${method} matches Herdr's panelButton`); + } + assert.deepEqual(state.commands, [], "render must have no side effects"); + let stopped = false; + button.calls.find(([name]) => name === "on_mouse_down")[1][1]({}, { stop_propagation() { stopped = true; } }); + assert.equal(stopped, true); + assert.equal(launcher.init, undefined); + assert.doesNotMatch(source, /layout\.|setShared|exec\(|fetch\(|timer|Overlay|readTextFile|writeTextFile/); +}); + +test("a click shows the space's Insights tab instead of opening another", () => { + state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }, { id: "t2" }] }]; + state.panes = [{ pane: "p1", mod: "terminal", tab: "t1" }, { pane: "p2", mod: "insights", tab: "t2" }]; + state.commands.length = 0; + const launcher = new Launcher(); + click(launcher); + click(launcher); + assert.deepEqual(state.commands, [["pane.focus", { pane: "p2" }], ["pane.focus", { pane: "p2" }]]); +}); + +test("a reused dashboard is focused and revealed behind another pane's zoom", () => { + state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }, { id: "t2" }] }]; + state.panes = [ + { pane: "terminal", mod: "terminal", tab: "t2", focused: true, zoomed: true }, + { pane: "dashboard", mod: "insights", tab: "t2", focused: false, zoomed: false }, + { pane: "other", mod: "terminal", tab: "t1", zoomed: true }, + ]; + state.commands.length = 0; + click(new Launcher()); + assert.deepEqual(state.commands, [ + ["pane.zoom", { pane: "terminal" }], + ["pane.focus", { pane: "dashboard" }], + ]); +}); + +test("reusing an already zoomed dashboard preserves its zoom", () => { + state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; + state.panes = [{ pane: "dashboard", mod: "insights", tab: "t1", zoomed: true }]; + state.commands.length = 0; + click(new Launcher()); + assert.deepEqual(state.commands, [["pane.focus", { pane: "dashboard" }]]); +}); + +test("a click opens the Insights tab only when the space has none", () => { + state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; + state.panes = [{ mod: "terminal", tab: "t1" }]; + state.commands.length = 0; + click(new Launcher()); + assert.deepEqual(state.commands, [["tab.new", { name: "Insights", mod: "insights" }]]); +}); + +test("an Insights tab in another space never satisfies the current space", () => { + state.spaces = [ + { id: "s1", active: true, tabs: [{ id: "t1" }] }, + { id: "s2", active: false, tabs: [{ id: "t2" }] }, + ]; + state.panes = [{ mod: "insights", tab: "t2" }]; + state.commands.length = 0; + click(new Launcher()); + assert.deepEqual(state.commands, [["tab.new", { name: "Insights", mod: "insights" }]]); +}); + +test("launcher is a normal layout mod, never a floating window", async () => { + const launcher = await collectSourceFiles(PACKAGE_ROOT, "insights-launcher"); + assert.deepEqual(launcher.files.map((file) => file.relative), ["insights.svg", "main.js", "mod.json"]); + assert.deepEqual(launcher.modJson, { place: "none", title: "Insights" }); +}); + +test("layout composition keeps the original tabs code and preserves unrelated layout fields", () => { + const tabs = { id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs", props: { custom: true } } }; + const original = { id: "root", type: "column", theme: "ink", children: [tabs, { id: "main", type: "block", content: { tab: "active" } }] }; + const snapshot = structuredClone(original); + const integrated = withInsightsToolbar(original); + assert.deepEqual(original, snapshot, "input must not be mutated"); + assert.equal(integrated.theme, "ink"); + assert.deepEqual(integrated.children[1], original.children[1]); + const row = integrated.children[0]; + assert.equal(row.id, "tabs", "keep existing height/click-state ID on wrapper"); + assert.equal(row.type, "row"); + assert.deepEqual(row.size, { px: 42 }); + assert.equal(row.window_drag, true); + assert.equal(row.resizable, false); + assert.equal(row.content, undefined); + assert.deepEqual(row.children[0].content, tabs.content); + assert.deepEqual(row.children[0].size, { weight: 1 }); + assert.deepEqual(row.children[1], { id: "insights-toolbar-button", type: "block", size: { px: 104 }, content: { mod: "insights-launcher" } }); + assert.throws(() => withInsightsToolbar(integrated), /already contains/); + assert.throws(() => withInsightsToolbar({ id: "root", type: "row", children: [] }), /cannot be integrated safely/); + assert.throws(() => withInsightsToolbar({ id: "root", type: "row", children: [tabs, tabs] }), /cannot be integrated safely/); +}); diff --git a/tests/herdr-insights/toolbar-install.test.mjs b/tests/herdr-insights/toolbar-install.test.mjs new file mode 100644 index 0000000..8cf1aee --- /dev/null +++ b/tests/herdr-insights/toolbar-install.test.mjs @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { + APP_FILES, + PACKAGE_ROOT, + TOOLBAR_LAYOUT, + assertFailure, + assertStagedChecks, + assertSuccess, + createFakeRunner, + createSourceWithLauncher, + exists, + listFiles, + makeEnvironment, + readJson, + readLog, + runInstall, + runUninstall, + snapshotTree, + temporaryRoot, + writeTree, +} from "./install-fixtures.mjs"; + +test("standalone distribution installs toolbar without repo tooling or a floating window", async (t) => { + const root = await temporaryRoot(t); + const distribution = path.join(root, "herdr-insights"); + await fs.cp(PACKAGE_ROOT, distribution, { recursive: true }); + const appHome = path.join(root, "app"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); + const fake = await createFakeRunner(root); + const result = spawnSync(process.execPath, [path.join(distribution, "scripts/install.mjs")], { + cwd: distribution, + env: makeEnvironment(root, appHome, fake), + encoding: "utf8", + shell: false, + timeout: 30_000, + }); + assertSuccess(result); + assert.match(result.stdout, /herdr-app check passed/); + const log = await readLog(fake); + const checks = await assertStagedChecks(log, ["insights", "insights-launcher"], appHome); + assert.deepEqual(log.map((entry) => entry.args), [ + ["status"], + ...checks, + ["do", "layout.clone", '{"name":"main","as":"main-insights"}'], + ]); + await assert.rejects(fs.lstat(path.join(appHome, "data/insights/insights.json")), { code: "ENOENT" }); + assert.deepEqual((await fs.readdir(root)).sort(), + ["app", "fake bin", "fake herdr log.jsonl", "herdr-insights"]); +}); + +test("launcher installs, checks, reinstalls and uninstalls through the same safe ownership rules", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, APP_FILES); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + const log = await readLog(fake); + const checks = await assertStagedChecks(log, ["insights", "insights-launcher"], appHome); + assert.deepEqual(log.map((entry) => entry.args), [ + ["status"], + ...checks, + ["do", "layout.clone", '{"name":"main","as":"main-insights"}'], + ]); + const launcher = path.join(appHome, "mods/insights-launcher"); + assert.deepEqual(await listFiles(launcher), ["insights.svg", "main.js", "mod.json"]); + const manifest = await readJson(path.join(appHome, "data/insights/launcher-install-manifest.json")); + assert.equal(manifest.target, "mods/insights-launcher"); + assert.deepEqual(Object.keys(manifest.files), ["insights.svg", "main.js", "mod.json"]); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + assert.equal((await readLog(fake)).filter((entry) => entry.args[1] === "layout.clone").length, 1, + "reinstall must not clone another layout"); + assert.equal((await readLog(fake)).filter((entry) => entry.args[1] === "mod.open").length, 0, + "no floating window"); + for (const [relative, content] of Object.entries(APP_FILES)) { + assert.equal(await fs.readFile(path.join(appHome, relative), "utf8"), content); + } + await fs.writeFile(path.join(launcher, "main.js"), "local launcher edit\n"); + const before = await fs.readFile(path.join(appHome, "mods/insights/main.js"), "utf8"); + await fs.writeFile(path.join(sourceRoot, "mods/insights/main.js"), "new dashboard\n"); + const refused = runInstall({ appHome, fake, sourceRoot }); + assertFailure(refused); + assert.match(refused.stderr, /Owned file was edited locally/); + assert.equal(await fs.readFile(path.join(appHome, "mods/insights/main.js"), "utf8"), before); + assertSuccess(runUninstall({ appHome, fake })); + assert.equal(await exists(path.join(appHome, "layouts/main-insights.json")), false); + assert.equal(await exists(path.join(appHome, "data/insights/toolbar-install.json")), false); + assert.equal(await fs.readFile(path.join(launcher, "main.js"), "utf8"), "local launcher edit\n"); + assert.equal(await exists(path.join(launcher, "mod.json")), false); + assert.equal(await exists(path.join(appHome, "mods/insights")), false); +}); + +test("launcher collisions and links fail before either mod is written, even with --force", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const fake = await createFakeRunner(root); + for (const kind of ["unowned", "symlink"]) { + const appHome = path.join(root, kind); + const target = path.join(appHome, "mods/insights-launcher"); + if (kind === "unowned") await writeTree(target, { "main.js": "unowned\n" }); + else { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.symlink(path.join(sourceRoot, "mods/insights-launcher"), target); + } + const refused = runInstall({ appHome, fake, sourceRoot, extra: ["--force"] }); + assertFailure(refused); + assert.match(refused.stderr, /unowned collision|symbolic-link target/); + assert.equal(await exists(path.join(appHome, "mods/insights")), false); + } + assert.deepEqual(await readLog(fake), []); +}); + +test("launcher check or activation failures never open a dashboard tab", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + for (const fail of ["launcher-check", "open"]) { + const fake = await createFakeRunner(path.join(root, fail)); + const appHome = path.join(root, fail, "app"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); + const result = runInstall({ appHome, fake, sourceRoot, fail }); + assertFailure(result); + const log = await readLog(fake); + assert.equal(log.filter((entry) => entry.args[1] === "tab.new").length, 0); + if (fail === "launcher-check") { + assert.equal(log.filter((entry) => entry.args[0] === "do").length, 0); + } else { + assert.match(result.stderr, /layout.clone.*failed/); + } + } +}); + +test("uninstall preflights both targets before deleting either mod", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const fake = await createFakeRunner(root); + for (const kind of ["symlink", "unowned", "corrupt-manifest"]) { + const appHome = path.join(root, kind); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + const target = path.join(appHome, "mods/insights-launcher"); + if (kind === "symlink") { + await fs.rm(target, { recursive: true }); + await fs.symlink(path.join(sourceRoot, "mods/insights-launcher"), target); + } else if (kind === "unowned") { + await fs.unlink(path.join(appHome, "data/insights/launcher-install-manifest.json")); + } else { + await fs.writeFile(path.join(appHome, "data/insights/launcher-install-manifest.json"), "{broken"); + } + const before = await snapshotTree(appHome); + assertFailure(runUninstall({ appHome })); + assert.deepEqual(await snapshotTree(appHome), before, kind); + assert.equal(await exists(path.join(appHome, "mods/insights/main.js")), true); + } +}); + +test("toolbar refuses unsupported layouts and state symlinks before writing either mod", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const fake = await createFakeRunner(root); + for (const kind of ["unsupported", "symlink", "state-link", "state-directory-link"]) { + const appHome = path.join(root, kind); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); + if (kind === "state-link") { + await fs.mkdir(path.join(appHome, "layouts/.state")); + await fs.symlink(path.join(sourceRoot, "layouts/other.json"), path.join(appHome, "layouts/.state/main.json")); + } + if (kind === "state-directory-link") { + await fs.symlink(path.join(sourceRoot, "layouts"), path.join(appHome, "layouts/.state")); + } + if (kind === "unsupported") await fs.writeFile(path.join(appHome, "layouts/main.json"), '{"id":"root","type":"row","children":[]}'); + if (kind === "symlink") { + await fs.unlink(path.join(appHome, "layouts/main.json")); + await fs.symlink(path.join(sourceRoot, "layouts/other.json"), path.join(appHome, "layouts/main.json")); + } + const refused = runInstall({ appHome, fake, sourceRoot, extra: ["--force"] }); + assertFailure(refused); + assert.match(refused.stderr, /cannot be integrated safely|symbolic link/); + assert.equal(await exists(path.join(appHome, "mods/insights")), false); + assert.equal(await exists(path.join(appHome, "mods/insights-launcher")), false); + } +}); + +test("reinstall preserves edited personal layouts and uninstall fails closed before deleting any files", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, APP_FILES); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + const layoutFile = path.join(appHome, "layouts/main-insights.json"); + const original = await fs.readFile(layoutFile, "utf8"); + const edited = JSON.parse(original); + edited.ui_font = "My custom font"; + const custom = JSON.stringify(edited, null, 2) + "\n"; + await fs.writeFile(layoutFile, custom); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + assert.equal(await fs.readFile(layoutFile, "utf8"), custom); + const before = await Promise.all((await listFiles(appHome)).map(async (relative) => + [relative, await fs.readFile(path.join(appHome, relative), "utf8")])); + const refused = runUninstall({ appHome, fake }); + assertFailure(refused); + assert.match(refused.stderr, /Toolbar layout was edited locally/); + const after = await Promise.all((await listFiles(appHome)).map(async (relative) => + [relative, await fs.readFile(path.join(appHome, relative), "utf8")])); + assert.deepEqual(after, before); + await fs.writeFile(layoutFile, original); + assertSuccess(runUninstall({ appHome, fake })); + const log = await readLog(fake); + assert.equal(log.filter((entry) => entry.args[1] === "layout.clone").length, 1); + assert.deepEqual(log.at(-2).args, ["do", "layout.switch", '{"name":"main"}']); + assert.deepEqual(log.at(-1).args, ["status"]); + assert.equal(await fs.readFile(path.join(appHome, "layouts/main.json"), "utf8"), TOOLBAR_LAYOUT); +}); + +test("toolbar skips layout/state collisions, preserves click state, and can reinstall after uninstall", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const appHome = path.join(root, "app"); + const state = '{"nodes":{"panel":{"hidden":false}},"theme":"ink"}\n'; + await writeTree(appHome, { + ...APP_FILES, + "layouts/.state/main.json": state, + "layouts/main-insights.json": "unowned layout\n", + "layouts/.state/main-insights-2.json": "unowned click state\n", + }); + const outside = path.join(root, "outside-state.json"); + await fs.writeFile(outside, "outside state\n"); + await fs.symlink(outside, path.join(appHome, "layouts/.state/main-insights-3.json")); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + const recordFile = path.join(appHome, "data/insights/toolbar-install.json"); + assert.equal((await readJson(recordFile)).layout, "main-insights-4"); + const stateFile = path.join(appHome, "layouts/.state/main-insights-4.json"); + assert.equal(await fs.readFile(stateFile, "utf8"), state); + await fs.writeFile(stateFile, "user's updated click state\n"); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + assert.equal(await fs.readFile(stateFile, "utf8"), "user's updated click state\n"); + assertSuccess(runUninstall({ appHome, fake })); + assert.equal(await fs.readFile(stateFile, "utf8"), "user's updated click state\n"); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + assert.equal((await readJson(recordFile)).layout, "main-insights-5"); + assert.equal(await fs.readFile(path.join(appHome, "layouts/main-insights.json"), "utf8"), "unowned layout\n"); + assert.equal(await fs.readFile(path.join(appHome, "layouts/.state/main-insights-2.json"), "utf8"), "unowned click state\n"); + assert.equal(await fs.readFile(outside, "utf8"), "outside state\n"); +}); + +test("uninstall verifies the original layout exists and actually became selected", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + for (const fail of ["missing-source", "ignored-switch"]) { + const appHome = path.join(root, fail); + await writeTree(appHome, APP_FILES); + const fake = await createFakeRunner(path.join(root, `${fail}-runner`)); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + if (fail === "missing-source") await fs.unlink(path.join(appHome, "layouts/main.json")); + const refused = runUninstall({ appHome, fake, fail }); + assertFailure(refused); + assert.match(refused.stderr, /Original layout main is missing|Herdr did not leave/); + assert.equal(await exists(path.join(appHome, "layouts/main-insights.json")), true); + assert.equal(await exists(path.join(appHome, "mods/insights/main.js")), true); + assert.equal(await exists(path.join(appHome, "data/insights/toolbar-install.json")), true); + } +}); + +test("toolbar ownership records reject traversal and do not overwrite personal files", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSourceWithLauncher(root); + const fake = await createFakeRunner(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, { + ...APP_FILES, + "data/insights/toolbar-install.json": JSON.stringify({ version: 1, owner: "herdr-insights-toolbar", + source: "../escape", layout: "../escape-insights", hash: "a".repeat(64) }), + }); + const refused = runInstall({ appHome, fake, sourceRoot }); + assertFailure(refused); + assert.match(refused.stderr, /Invalid toolbar ownership record/); + assert.equal(await exists(path.join(appHome, "mods/insights")), false); + assert.deepEqual(await readLog(fake), []); + assertFailure(runUninstall({ appHome, fake })); +}); diff --git a/tests/herdr-insights/ui-system.test.mjs b/tests/herdr-insights/ui-system.test.mjs index b86d6a8..56d2d38 100644 --- a/tests/herdr-insights/ui-system.test.mjs +++ b/tests/herdr-insights/ui-system.test.mjs @@ -235,6 +235,6 @@ test("the shared visual module is theme-driven, shadow-free and part of the inst assert.doesNotMatch(mainSource, /shadow_md|shadow_sm|shadow_lg/); has(mainSource, /from "\.\/ui\.js"/); - const source = await collectSourceFiles(PACKAGE_ROOT); + const source = await collectSourceFiles(PACKAGE_ROOT, "insights"); assert.equal(source.files.some((file) => file.relative === "ui.js"), true, "installer copies mods/insights recursively"); });