From 250daeba135e5863f70b186b6a17040f78e0431c Mon Sep 17 00:00:00 2001 From: Maciej Krajowski-Kukiel Date: Mon, 22 Jun 2026 17:51:14 +0200 Subject: [PATCH] add post-install instructions --- CHANGELOG.md | 3 +- bin/modules.md | 26 +++++ lib/modules/downloadModule.js | 4 +- lib/modules/install.js | 16 ++- lib/modules/orchestrator.js | 4 +- lib/modules/paths.js | 7 ++ lib/modules/postInstall.js | 137 ++++++++++++++++++++++ lib/modules/show.js | 5 + lib/modules/uninstall.js | 6 +- lib/watch.js | 21 ++-- package-lock.json | 9 +- package.json | 1 + test/unit/installModule.test.js | 70 ++++++++++++ test/unit/modulesPostInstall.test.js | 165 +++++++++++++++++++++++++++ test/unit/showModule.test.js | 41 +++++++ test/unit/smartInstall.test.js | 43 +++++++ 16 files changed, 531 insertions(+), 27 deletions(-) create mode 100644 lib/modules/postInstall.js create mode 100644 test/unit/modulesPostInstall.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e380518f..9c201379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ * **Per-module registry overrides** — declare a `registries` map in `pos-module.json` to resolve specific modules from a private or custom registry. * `pos-cli modules install --frozen` — CI-safe install. Uses the lock file as-is, performs no registry resolution, and fails fast if the lock file is missing or stale. * `pos-cli modules install --dev` / `pos-cli modules update --dev` — manage `devDependencies` independently from `dependencies`. `install --dev` adds the module to `devDependencies`; without a name, `--dev` includes both sections in the resolution. -* `pos-cli modules show ` — display all available versions of a module from the registry. +* `pos-cli modules show ` — display all available versions of a module from the registry, and re-display the module's post-install message when it is installed locally. +* **Module post-install messages** — modules can ship declarative setup instructions (a `postInstall.message` field in `pos-module.json`, or a `POST_INSTALL.md` file) that pos-cli prints after the module is downloaded, analogous to RubyGems' `post_install_message` / Homebrew's caveats. The text is **never executed** (no npm-style `postinstall` hook) and is sanitised before printing. Shown for modules downloaded during a run plus the explicitly named module; `--frozen` (CI) installs print nothing. See `bin/modules.md` for the authoring format. * `pos-cli modules uninstall ` — remove a module from `pos-module.json`, re-resolve the dependency tree, and clean up the `modules/` directory. Use `--dev` to remove from `devDependencies`. * `pos-cli modules build` — package the current module into a release archive without uploading. * `pos-cli modules migrate` — upgrade an existing project to the new manifest format. Runs two independent, idempotent phases: diff --git a/bin/modules.md b/bin/modules.md index 522aac75..9a7436f7 100644 --- a/bin/modules.md +++ b/bin/modules.md @@ -118,3 +118,29 @@ sequenceDiagram pos-cli->>Platform: send inline module files (overwrites) Platform->>Platform: apply overwrites ``` + +## Post-install messages (module authors) + +A module may ship **declarative** setup instructions that pos-cli prints after the module is downloaded — for example, "run this generator next". This is the platformOS analogue of RubyGems' `post_install_message` and Homebrew's `caveats`. + +These messages are **text only**. pos-cli never executes module-supplied code at install time (the npm `postinstall` supply-chain hole we are deliberately avoiding). If a module needs a command run, it prints the command for the user to copy. + +Declare a message in the module's own `pos-module.json` (the one that ships in the archive, at `modules//pos-module.json`): + +```json +{ + "machine_name": "common-styling", + "postInstall": { + "message": "common-styling installed.\n\nScaffold a layout:\n\n pos-cli generate run modules/common-styling/generators/install\n" + } +} +``` + +For longer instructions, omit `postInstall.message` and ship a `modules//POST_INSTALL.md` file instead — it is used as a fallback and also renders on the marketplace. + +Rules: + +- Messages are printed for modules **downloaded** during a run (including transitive dependencies installed for the first time), plus the explicitly named module on `pos-cli modules install ` (even when already present). +- `--frozen` (CI) installs print nothing. +- Module-supplied text is sanitised before printing: ANSI/OSC escape sequences (via `strip-ansi`) and bare control characters are stripped, and the message is length-capped. +- Re-view a module's message any time with `pos-cli modules show ` (when the module is installed locally). diff --git a/lib/modules/downloadModule.js b/lib/modules/downloadModule.js index 20c7c639..02afa3da 100644 --- a/lib/modules/downloadModule.js +++ b/lib/modules/downloadModule.js @@ -6,9 +6,7 @@ import Portal from '../portal.js'; import fs from 'fs'; import path from 'path'; import os from 'os'; - -const getModulesDir = () => path.join(process.cwd(), 'modules'); -const getModulePath = (name) => path.join(getModulesDir(), name); +import { getModulesDir, getModulePath } from './paths.js'; /** * Downloads and extracts a single module archive. diff --git a/lib/modules/install.js b/lib/modules/install.js index 96920893..cc1ed728 100644 --- a/lib/modules/install.js +++ b/lib/modules/install.js @@ -3,6 +3,7 @@ import { POS_MODULE_FILE } from './paths.js'; import { parseAndValidateModuleArg } from './parseModuleArg.js'; import { createGetVersions, findVersionWithContext } from './registry.js'; import { resolveAndDownload, frozenInstall, smartInstall } from './orchestrator.js'; +import { printPostInstallMessages } from './postInstall.js'; // Returns the updated modules map, or null when the module is already installed // and no explicit version was requested (install is conditional, unlike update). @@ -40,6 +41,7 @@ const installModules = async (spinner, moduleNameWithVersion, { dev = false, fro if (frozen) { if (moduleNameWithVersion) throw new Error('Cannot add a new module with --frozen'); + // Returns early: --frozen (CI) installs intentionally print no post-install messages. return frozenInstall(spinner, prodModules, devModules, registryUrl, { includeDev: dev }); } @@ -47,9 +49,11 @@ const installModules = async (spinner, moduleNameWithVersion, { dev = false, fro let devMods = devModules; const getVersions = createGetVersions(registryUrl, registries); let added = null; + let explicitName = null; if (moduleNameWithVersion) { const [moduleName, moduleVersion] = parseAndValidateModuleArg(moduleNameWithVersion); + explicitName = moduleName; const targetSection = dev ? devMods : prodMods; const updated = await addNewModule(moduleName, moduleVersion, targetSection, getVersions, registryUrl); if (updated) { @@ -65,16 +69,17 @@ const installModules = async (spinner, moduleNameWithVersion, { dev = false, fro return; } + let result; if (moduleNameWithVersion) { // An explicit module name was given: always re-resolve. The manifest may have just // changed (added === moduleName) or the user is forcing a re-resolution of an already // present module — either way the lock cannot be trusted as authoritative. const newlyAdded = added ? new Set([added]) : new Set(); - await resolveAndDownload(spinner, prodMods, devMods, registryUrl, getVersions, { registries, includeDev: dev, newlyAdded }); + result = await resolveAndDownload(spinner, prodMods, devMods, registryUrl, getVersions, { registries, includeDev: dev, newlyAdded }); } else { // No-arg install: use the lock file when it is valid, fall back to full resolution // only when the lock is absent or stale. Matches Bundler / npm install semantics. - await smartInstall(spinner, prodMods, devMods, registryUrl, getVersions, { registries, includeDev: dev }); + result = await smartInstall(spinner, prodMods, devMods, registryUrl, getVersions, { registries, includeDev: dev }); } // Write the manifest only after successful resolution so pos-module.json is never @@ -87,6 +92,13 @@ const installModules = async (spinner, moduleNameWithVersion, { dev = false, fro spinner.start(); spinner.succeed(`Added module: ${added}@${version} to ${section} in ${POS_MODULE_FILE}`); } + + // Print any declarative post-install instructions the installed modules ship. + // Shown for modules actually downloaded this run (incl. transitive deps installed + // for the first time), plus the explicitly named module even when already present — + // matching `gem install` re-showing post_install_message on an explicit install. + const toNotify = [explicitName, ...(result?.downloaded || [])].filter(Boolean); + printPostInstallMessages(toNotify); }; export { addNewModule, installModules }; diff --git a/lib/modules/orchestrator.js b/lib/modules/orchestrator.js index 7d27b8df..b83da6dd 100644 --- a/lib/modules/orchestrator.js +++ b/lib/modules/orchestrator.js @@ -109,7 +109,7 @@ const resolveAndDownload = async (spinner, prodModules, devModules = {}, registr const allPrevious = includeDev ? { ...prevProd, ...prevDev } : prevProd; printDiff(allPrevious, allResolved); - return { resolvedProd, resolvedDev, path: 'resolved' }; + return { resolvedProd, resolvedDev, downloaded: Object.keys(toDownload), path: 'resolved' }; }; /** Returns true when the lock file has at least one entry in prod or dev sections. */ @@ -217,7 +217,7 @@ const frozenInstall = async (spinner, prodModules, devModules = {}, registryUrl await downloadAllModules(toDownload, getRegistryUrl); spinner.succeed(`Modules downloaded successfully${skipNote}`); - return { resolvedProd: lockProd, resolvedDev: lockDev, path: 'frozen' }; + return { resolvedProd: lockProd, resolvedDev: lockDev, downloaded: Object.keys(toDownload), path: 'frozen' }; }; /** diff --git a/lib/modules/paths.js b/lib/modules/paths.js index 9c3133b7..f6bce334 100644 --- a/lib/modules/paths.js +++ b/lib/modules/paths.js @@ -3,9 +3,16 @@ * Import from here to avoid the same string being defined in multiple files. */ +import path from 'path'; + export const POS_MODULE_FILE = 'pos-module.json'; export const POS_MODULE_LOCK_FILE = 'pos-module.lock.json'; export const LEGACY_POS_MODULES_FILE = 'app/pos-modules.json'; export const LEGACY_POS_MODULES_LOCK_FILE = 'app/pos-modules.lock.json'; export const APP_POS_MODULE_FILE = 'app/pos-module.json'; export const FALLBACK_REGISTRY_URL = 'https://partners.platformos.com'; + +/** Absolute path to the project's `modules/` directory. */ +export const getModulesDir = () => path.join(process.cwd(), 'modules'); +/** Absolute path to an installed module's directory: `modules/`. */ +export const getModulePath = (name) => path.join(getModulesDir(), name); diff --git a/lib/modules/postInstall.js b/lib/modules/postInstall.js new file mode 100644 index 00000000..ea905158 --- /dev/null +++ b/lib/modules/postInstall.js @@ -0,0 +1,137 @@ +import fs from 'fs'; +import path from 'path'; +import stripAnsi from 'strip-ansi'; +import logger from '../logger.js'; +import { POS_MODULE_FILE, getModulePath } from './paths.js'; + +/** + * Post-install messages — declarative, never executable. + * + * A module may ship setup instructions that pos-cli prints after the module is + * downloaded (e.g. "run this generator next"). This mirrors RubyGems' + * `post_install_message` and Homebrew's `caveats`: pure text, no code. pos-cli + * deliberately does NOT run module-supplied code at install time — that is the + * npm `postinstall` supply-chain hole we are avoiding. + * + * A message is sourced, in order, from the installed module on disk: + * 1. `postInstall.message` in modules//pos-module.json (short notes) + * 2. modules//POST_INSTALL.md (longer content) + */ + +const POST_INSTALL_MD = 'POST_INSTALL.md'; + +// Hard cap so a misbehaving (or malicious) module cannot flood the terminal. +// Anything longer is truncated with a pointer back to the module. +const MAX_MESSAGE_LENGTH = 4000; + +// Unicode control chars (General_Category=Cc: C0, DEL, C1), except tab and +// newline. These are the bare control bytes that survive ANSI stripping (e.g. +// \r line-overwrites, BEL, NUL): strip-ansi removes ESC-introduced escape +// sequences, not lone control chars. The v flag set-subtraction (--) names the +// Unicode category and carves out the two we keep, instead of hand-listing +// codepoint gaps. The hard part (the CSI/OSC escape grammar) is still delegated +// to strip-ansi, not hand-rolled. +const CONTROL_CHARS = /[\p{Cc}--[\t\n]]/gv; + +/** + * Strips terminal-unsafe content from module-supplied text. The message comes + * from a downloaded archive and is therefore untrusted: without this a module + * could embed ANSI/OSC escape sequences (cursor moves, hyperlinks, colour + * resets) that manipulate the user's terminal. + * + * Escape-sequence removal is delegated to strip-ansi (the battle-tested + * ansi-regex used across the npm ecosystem, incl. npm itself) rather than + * hand-rolled; we then drop the few remaining bare control characters, keeping + * tabs and newlines. + */ +const stripUnsafe = (text) => stripAnsi(text).replace(CONTROL_CHARS, ''); + +/** Cleans, bounds, and validates a raw message. Returns null when empty. */ +const normalize = (raw) => { + if (typeof raw !== 'string') return null; + // trimEnd (not trim) preserves any intentional leading indentation; an + // all-whitespace message collapses to '' here, so a single emptiness check suffices. + const cleaned = stripUnsafe(raw).trimEnd(); + if (!cleaned) return null; + if (cleaned.length > MAX_MESSAGE_LENGTH) { + return `${cleaned.slice(0, MAX_MESSAGE_LENGTH).trimEnd()}\n…(message truncated — see the module README)`; + } + return cleaned; +}; + +/** + * Reads and transforms a file, returning null when it is absent or unreadable. + * A single read (no existsSync pre-check) handles the missing-file case via the + * ENOENT catch; any other read/parse failure is logged at Debug and treated as + * "no message" so a broken module never aborts the install. + */ +const safeReadFile = (filePath, transform) => { + try { + return transform(fs.readFileSync(filePath, 'utf8')); + } catch (e) { + if (e.code !== 'ENOENT') logger.Debug(`[postInstall] could not read ${filePath}: ${e.message}`); + return null; + } +}; + +/** + * Returns the post-install message for an installed module, or null when the + * module declares none / is not on disk. Reads the module's OWN manifest under + * modules// — not the project-root manifest. + */ +const readPostInstall = (moduleName) => { + const dir = getModulePath(moduleName); + + const fromManifest = safeReadFile(path.join(dir, POS_MODULE_FILE), (raw) => + normalize(JSON.parse(raw)?.postInstall?.message) + ); + if (fromManifest) return fromManifest; + + return safeReadFile(path.join(dir, POST_INSTALL_MD), normalize); +}; + +const BOX_WIDTH = 64; + +/** + * Formats one message: a header/footer rule framing the body on a TTY, a plain + * labelled block otherwise. The body is left flush (never indented or wrapped) so + * the commands these messages typically contain stay copy-paste-clean. Both rules + * are the same total width, and the header grows to fit a long module name. + */ +const formatMessage = (moduleName, message, { isTTY }) => { + if (!isTTY) { + return `\npost-install (${moduleName}):\n${message}\n`; + } + const header = `── ${moduleName} `; + const width = Math.max(BOX_WIDTH, header.length); + const top = header + '─'.repeat(width - header.length); + const bottom = '─'.repeat(width); + return `\n${top}\n${message}\n${bottom}\n`; +}; + +/** + * Prints post-install messages for the given modules (in the order supplied, + * de-duplicated). Modules without a message are skipped silently. + * + * @param {string[]} moduleNames + * @param {Object} [options] + * @param {boolean} [options.isTTY] Render the bordered box (vs. plain block for pipes). + * Defaults to whether stdout is a TTY. + * @param {Function} [options.read=readPostInstall] Injectable reader (for tests). + * @returns {string[]} names of modules that actually had a message printed. + */ +const printPostInstallMessages = (moduleNames, { isTTY = Boolean(process.stdout.isTTY), read = readPostInstall } = {}) => { + const seen = new Set(); + const printed = []; + for (const name of moduleNames) { + if (seen.has(name)) continue; + seen.add(name); + const message = read(name); + if (!message) continue; + logger.Print(formatMessage(name, message, { isTTY })); + printed.push(name); + } + return printed; +}; + +export { readPostInstall, printPostInstallMessages, normalize, stripUnsafe }; diff --git a/lib/modules/show.js b/lib/modules/show.js index 5179d1c1..ca2a3206 100644 --- a/lib/modules/show.js +++ b/lib/modules/show.js @@ -2,6 +2,7 @@ import semver from 'semver'; import Portal from '../portal.js'; import logger from '../logger.js'; import { getRegistryUrl } from './configFiles.js'; +import { printPostInstallMessages } from './postInstall.js'; const showModuleVersions = async (spinner, moduleName) => { const registryUrl = getRegistryUrl(); @@ -31,6 +32,10 @@ const showModuleVersions = async (spinner, moduleName) => { for (const v of sorted) { logger.Info(` ${v}`, { hideTimestamp: true }); } + + // Re-show the module's post-install instructions when it is installed locally, + // so users can recover guidance they scrolled past at install time (cf. `brew info`). + printPostInstallMessages([moduleName]); }; export { showModuleVersions }; diff --git a/lib/modules/uninstall.js b/lib/modules/uninstall.js index 8747063b..2103fab0 100644 --- a/lib/modules/uninstall.js +++ b/lib/modules/uninstall.js @@ -1,12 +1,8 @@ import fs from 'fs'; -import path from 'path'; import { readConfig, writePosModules, writePosModulesLock, getRegistryUrl } from './configFiles.js'; import { createGetVersions } from './registry.js'; import { resolveAndDownload } from './orchestrator.js'; -import { POS_MODULE_FILE } from './paths.js'; - -const getModulesDir = () => path.join(process.cwd(), 'modules'); -const getModulePath = (name) => path.join(getModulesDir(), name); +import { POS_MODULE_FILE, getModulePath } from './paths.js'; /** * Removes a module from pos-module.json, deletes its directory from disk, diff --git a/lib/watch.js b/lib/watch.js index cdc6e522..4bc13183 100644 --- a/lib/watch.js +++ b/lib/watch.js @@ -35,20 +35,21 @@ const filePathUnixified = filePath => .replace(new RegExp(`^${dir.LEGACY_APP}/`), ''); const moduleAssetRegex = new RegExp('^modules/\\w+/public/assets'); -// Directories that must never be watched. chokidar v4+ dropped fsevents, so on -// macOS each watched directory costs one file descriptor (kqueue). Pruning these -// keeps pos-cli well under the OS limit and avoids EMFILE on large projects. -// None of these are ever synced, so ignoring them is safe. -const WATCH_IGNORED_DIRS = new Set(['node_modules', '.git']); +// Paths that must never be watched. chokidar v4+ dropped fsevents, so on macOS +// each watched directory costs one file descriptor (kqueue). Pruning these keeps +// pos-cli well under the OS limit and avoids EMFILE on large projects. None of +// these are ever synced, so ignoring them is safe. +// +// Matches `node_modules` or `.git` as a full path segment, or a `.DS_Store` +// basename. A single precomputed regex avoids allocating a split array on every +// call — and watchIgnored runs once per path chokidar traverses (the very +// large-tree scenario this exists to tame). +const WATCH_IGNORED_RE = /(^|\/)(node_modules|\.git)(\/|$)|(^|\/)\.DS_Store$/; // chokidar v4+ removed glob support: a string matcher is compared literally, so // the old `ignored: ['**/.DS_Store']` silently stopped excluding anything. The // supported form is a function `(path, stats?) => boolean` testing the full path. -const watchIgnored = filePath => { - const normalizedPath = filePath.replace(/\\/g, '/'); - if (path.basename(normalizedPath) === '.DS_Store') return true; - return normalizedPath.split('/').some(segment => WATCH_IGNORED_DIRS.has(segment)); -}; +const watchIgnored = filePath => WATCH_IGNORED_RE.test(filePath.replace(/\\/g, '/')); // Without this handler an EMFILE (too many open files) — emitted by chokidar as // an 'error' event — becomes an uncaught error and crashes pos-cli. Keep the diff --git a/package-lock.json b/package-lock.json index b2b6c115..9ff2437e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "prompts": "^2.4.2", "semver": "^7.7.4", "shelljs": "^0.10.0", + "strip-ansi": "^7.2.0", "text-table": "^0.2.0", "unzipper": "^0.12.3", "update-notifier": "^7.3.1", @@ -8265,12 +8266,12 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" diff --git a/package.json b/package.json index 3772b451..bf63dc08 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "prompts": "^2.4.2", "semver": "^7.7.4", "shelljs": "^0.10.0", + "strip-ansi": "^7.2.0", "text-table": "^0.2.0", "unzipper": "^0.12.3", "update-notifier": "^7.3.1", diff --git a/test/unit/installModule.test.js b/test/unit/installModule.test.js index a3a6a919..92d4de6c 100644 --- a/test/unit/installModule.test.js +++ b/test/unit/installModule.test.js @@ -7,6 +7,7 @@ import { parseModuleArg } from '#lib/modules/parseModuleArg.js'; import { makeGetVersions } from '#lib/modules/registry.js'; import { frozenInstall } from '#lib/modules/orchestrator.js'; import { downloadAllModules, modulesToDownload, modulesNotOnDisk } from '#lib/modules/downloadModule.js'; +import { printPostInstallMessages } from '#lib/modules/postInstall.js'; import { mod, makeRegistry } from '#test/utils/moduleRegistry.js'; import { withTmpDir } from '#test/utils/withTmpDir.js'; import { makeSpinner } from '#test/utils/spinnerMock.js'; @@ -18,6 +19,12 @@ vi.mock('#lib/modules/downloadModule.js', () => ({ modulesNotOnDisk: vi.fn().mockReturnValue({}) })); +// Stubbed so we can assert which module names installModules forwards for +// post-install notification, without touching the real filesystem-reading impl. +vi.mock('#lib/modules/postInstall.js', () => ({ + printPostInstallMessages: vi.fn().mockReturnValue([]) +})); + // Mocked so that installModules tests that trigger the resolve path do not // make real network calls. Configured per-test via Portal.moduleVersions.mockResolvedValue(). vi.mock('#lib/portal.js', () => ({ @@ -534,3 +541,66 @@ describe('installModules — routing', () => { expect(spinner.succeed).not.toHaveBeenCalledWith('Using frozen lock file'); }); }); + +// --------------------------------------------------------------------------- +// installModules — post-install notification wiring +// +// installModules builds the notify list as [explicitName, ...downloaded] and +// hands it to printPostInstallMessages (which de-dupes / filters internally — +// covered in modulesPostInstall.test.js). These tests pin the wiring: which +// names reach that call, and that --frozen suppresses it entirely. +// --------------------------------------------------------------------------- + +describe('installModules — post-install notification', () => { + beforeEach(() => vi.clearAllMocks()); + + test('named install notifies the explicit module even when nothing is downloaded', async () => { + writeManifestForRouting({ dependencies: { core: '^2.0.0' } }); + writeLock({ dependencies: { core: '2.0.0' }, devDependencies: {} }); + modulesToDownload.mockReturnValue({}); // already on disk → no downloads + Portal.moduleVersions.mockResolvedValue([ + { module: 'core', versions: { '2.0.0': { dependencies: {} } } } + ]); + + await installModules(spinner, 'core', {}); + + expect(printPostInstallMessages).toHaveBeenCalledTimes(1); + expect(printPostInstallMessages.mock.calls[0][0]).toEqual(['core']); + }); + + test('notifies the explicit module plus every freshly downloaded module', async () => { + writeManifestForRouting({ dependencies: { core: '^2.0.0' } }); + // core pulls in transitive dep 'helper'; both are fetched this run. + modulesToDownload.mockReturnValue({ core: '2.0.0', helper: '1.0.0' }); + Portal.moduleVersions.mockResolvedValue([ + { module: 'core', versions: { '2.0.0': { dependencies: { helper: '^1.0.0' } } } }, + { module: 'helper', versions: { '1.0.0': { dependencies: {} } } } + ]); + + await installModules(spinner, 'core', {}); + + // explicit name first, then downloaded keys; de-duplication is downstream. + expect(printPostInstallMessages.mock.calls[0][0]).toEqual(['core', 'core', 'helper']); + }); + + test('no-arg install notifies only the downloaded modules (no explicit name)', async () => { + writeManifestForRouting({ dependencies: { core: '^2.0.0' } }); + modulesToDownload.mockReturnValue({ core: '2.0.0' }); + Portal.moduleVersions.mockResolvedValue([ + { module: 'core', versions: { '2.0.0': { dependencies: {} } } } + ]); + + await installModules(spinner, undefined, {}); + + expect(printPostInstallMessages.mock.calls[0][0]).toEqual(['core']); + }); + + test('--frozen install prints no post-install messages (CI mode)', async () => { + writeManifestForRouting({ dependencies: { core: '^2.0.0' } }); + writeLock({ dependencies: { core: '2.0.0' }, devDependencies: {} }); + + await installModules(spinner, undefined, { frozen: true }); + + expect(printPostInstallMessages).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/modulesPostInstall.test.js b/test/unit/modulesPostInstall.test.js new file mode 100644 index 00000000..e6fa6833 --- /dev/null +++ b/test/unit/modulesPostInstall.test.js @@ -0,0 +1,165 @@ +import { describe, test, expect, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +// Capture logger.Print output so we can assert on what reaches the terminal. +vi.mock('#lib/logger.js', () => ({ + default: { + Print: vi.fn(), + Debug: vi.fn(), + Warn: vi.fn(), + Error: vi.fn(), + Info: vi.fn() + } +})); + +import logger from '#lib/logger.js'; +import { + readPostInstall, + printPostInstallMessages, + normalize, + stripUnsafe +} from '#lib/modules/postInstall.js'; +import { withTmpDir } from '#test/utils/withTmpDir.js'; + +const getTmpDir = withTmpDir(); + +const writeModuleManifest = (name, manifest) => { + const dir = path.join(getTmpDir(), 'modules', name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'pos-module.json'), JSON.stringify(manifest, null, 2)); +}; + +const writeModuleFile = (name, file, content) => { + const dir = path.join(getTmpDir(), 'modules', name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, file), content); +}; + +const ESC = '\u001b'; // escape char + +// --------------------------------------------------------------------------- +// stripUnsafe / normalize +// --------------------------------------------------------------------------- + +describe('stripUnsafe', () => { + test('removes ANSI colour (CSI) sequences', () => { + expect(stripUnsafe(`${ESC}[31mred${ESC}[0m`)).toBe('red'); + }); + + test('removes OSC sequences (e.g. terminal hyperlinks)', () => { + expect(stripUnsafe(`${ESC}]8;;http://evil${ESC}\\click`)).toBe('click'); + }); + + test('removes carriage returns and other control chars but keeps tab and newline', () => { + expect(stripUnsafe('a\r\nb\tcd')).toBe('a\nb\tc' + 'd'); + }); +}); + +describe('normalize', () => { + test('returns null for non-strings', () => { + expect(normalize(42)).toBeNull(); + expect(normalize(undefined)).toBeNull(); + expect(normalize({ message: 'x' })).toBeNull(); + }); + + test('returns null for blank/whitespace-only messages', () => { + expect(normalize(' \n\t ')).toBeNull(); + expect(normalize(`${ESC}[0m`)).toBeNull(); + }); + + test('trims trailing whitespace but preserves internal formatting', () => { + expect(normalize('line one\nline two \n\n')).toBe('line one\nline two'); + }); + + test('truncates very long messages and appends a pointer', () => { + const long = 'x'.repeat(5000); + const out = normalize(long); + expect(out.length).toBeLessThan(5000); + expect(out).toMatch(/truncated/); + }); +}); + +// --------------------------------------------------------------------------- +// readPostInstall +// --------------------------------------------------------------------------- + +describe('readPostInstall', () => { + test('returns null when the module is not on disk', () => { + expect(readPostInstall('ghost')).toBeNull(); + }); + + test('returns null when the module declares no postInstall', () => { + writeModuleManifest('core', { machine_name: 'core', version: '2.0.0' }); + expect(readPostInstall('core')).toBeNull(); + }); + + test('reads postInstall.message from the module manifest', () => { + writeModuleManifest('common-styling', { + machine_name: 'common-styling', + postInstall: { message: 'Run the install generator next.' } + }); + expect(readPostInstall('common-styling')).toBe('Run the install generator next.'); + }); + + test('sanitizes the manifest message', () => { + writeModuleManifest('common-styling', { + postInstall: { message: `${ESC}[31mDanger${ESC}[0m text` } + }); + expect(readPostInstall('common-styling')).toBe('Danger text'); + }); + + test('falls back to POST_INSTALL.md when manifest has no message', () => { + writeModuleManifest('docs-module', { machine_name: 'docs-module' }); + writeModuleFile('docs-module', 'POST_INSTALL.md', '# Setup\nDo the thing.\n'); + expect(readPostInstall('docs-module')).toBe('# Setup\nDo the thing.'); + }); + + test('manifest message wins over POST_INSTALL.md', () => { + writeModuleManifest('both', { postInstall: { message: 'from manifest' } }); + writeModuleFile('both', 'POST_INSTALL.md', 'from markdown'); + expect(readPostInstall('both')).toBe('from manifest'); + }); + + test('does not throw on malformed manifest JSON', () => { + const dir = path.join(getTmpDir(), 'modules', 'broken'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'pos-module.json'), '{ not valid json '); + expect(readPostInstall('broken')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// printPostInstallMessages +// --------------------------------------------------------------------------- + +describe('printPostInstallMessages', () => { + test('prints nothing and returns [] when no module has a message', () => { + const printed = printPostInstallMessages(['a', 'b'], { read: () => null }); + expect(printed).toEqual([]); + expect(logger.Print).not.toHaveBeenCalled(); + }); + + test('prints only modules that have a message, in order, de-duplicated', () => { + const read = (name) => (name === 'core' ? null : `msg for ${name}`); + const printed = printPostInstallMessages(['user', 'core', 'user', 'chat'], { read, isTTY: false }); + expect(printed).toEqual(['user', 'chat']); + expect(logger.Print).toHaveBeenCalledTimes(2); + }); + + test('non-TTY output is a plain labelled block (no box drawing)', () => { + printPostInstallMessages(['user'], { read: () => 'hello', isTTY: false }); + const out = logger.Print.mock.calls[0][0]; + expect(out).toContain('post-install (user):'); + expect(out).toContain('hello'); + expect(out).not.toContain('─'); + }); + + test('TTY output draws a bordered box', () => { + printPostInstallMessages(['user'], { read: () => 'hello', isTTY: true }); + const out = logger.Print.mock.calls[0][0]; + expect(out).toContain('─'); + expect(out).toContain('user'); + expect(out).toContain('hello'); + }); +}); diff --git a/test/unit/showModule.test.js b/test/unit/showModule.test.js index e3c6d698..16e7b9cb 100644 --- a/test/unit/showModule.test.js +++ b/test/unit/showModule.test.js @@ -6,7 +6,14 @@ vi.mock('#lib/portal.js', () => ({ default: { moduleVersions: vi.fn() } })); +// Stubbed to assert show re-surfaces the module's post-install message; +// the real impl reads modules// from disk and is tested separately. +vi.mock('#lib/modules/postInstall.js', () => ({ + printPostInstallMessages: vi.fn().mockReturnValue([]) +})); + import Portal from '#lib/portal.js'; +import { printPostInstallMessages } from '#lib/modules/postInstall.js'; const spinner = makeSpinner(); @@ -163,3 +170,37 @@ describe('showModuleVersions — registry errors', () => { ); }); }); + +// --------------------------------------------------------------------------- +// Post-install message re-display (cf. `brew info`) +// --------------------------------------------------------------------------- + +describe('showModuleVersions — post-install message', () => { + test('re-surfaces the module post-install message after listing versions', async () => { + Portal.moduleVersions.mockResolvedValue([ + { module: 'core', versions: { '2.0.0': {} } } + ]); + + await showModuleVersions(spinner, 'core'); + + expect(printPostInstallMessages).toHaveBeenCalledTimes(1); + expect(printPostInstallMessages.mock.calls[0][0]).toEqual(['core']); + }); + + test('does not attempt to re-surface a message when the module has no versions', async () => { + Portal.moduleVersions.mockResolvedValue([ + { module: 'empty-mod', versions: {} } + ]); + + await showModuleVersions(spinner, 'empty-mod'); + + expect(printPostInstallMessages).not.toHaveBeenCalled(); + }); + + test('does not attempt to re-surface a message when the module is not found', async () => { + Portal.moduleVersions.mockResolvedValue([]); + + await expect(showModuleVersions(spinner, 'ghost')).rejects.toThrow(); + expect(printPostInstallMessages).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/smartInstall.test.js b/test/unit/smartInstall.test.js index c7ca27c8..35821572 100644 --- a/test/unit/smartInstall.test.js +++ b/test/unit/smartInstall.test.js @@ -18,6 +18,7 @@ import { mod, makeRegistry } from '#test/utils/moduleRegistry.js'; import { withTmpDir } from '#test/utils/withTmpDir.js'; import { makeSpinner } from '#test/utils/spinnerMock.js'; import { makeFileHelpers } from '#test/utils/fileHelpers.js'; +import { modulesToDownload, modulesNotOnDisk } from '#lib/modules/downloadModule.js'; vi.mock('#lib/modules/downloadModule.js', () => ({ downloadAllModules: vi.fn().mockResolvedValue(undefined), @@ -308,3 +309,45 @@ describe('frozenInstall — constraint validation', () => { ).rejects.toThrow(/Run pos-cli modules install/); }); }); + +// --------------------------------------------------------------------------- +// `downloaded` field — names of modules actually fetched this run. +// This is what install.js feeds to printPostInstallMessages, so the contents +// (not just the presence of the key) matter. +// --------------------------------------------------------------------------- + +describe('downloaded field', () => { + const { writeLock: writeDownloadedLock } = makeFileHelpers(getTmpDir); + beforeEach(() => vi.clearAllMocks()); + + test('resolve path reports the keys of modulesToDownload', async () => { + // No lock → resolve path. modulesToDownload decides what is actually fetched. + modulesToDownload.mockReturnValue({ core: '2.0.0', user: '5.0.0' }); + const getVersions = makeRegistry( + mod('core', { '2.0.0': {} }), + mod('user', { '5.0.0': {} }) + ); + + const result = await smartInstall(spinner, { core: '^2.0.0', user: '^5.0.0' }, {}, REGISTRY, getVersions); + + expect(result.downloaded).toEqual(['core', 'user']); + }); + + test('resolve path reports [] when nothing needs downloading (all up to date)', async () => { + modulesToDownload.mockReturnValue({}); // everything already on disk at the right version + const getVersions = makeRegistry(mod('core', { '2.0.0': {} })); + + const result = await smartInstall(spinner, { core: '^2.0.0' }, {}, REGISTRY, getVersions); + + expect(result.downloaded).toEqual([]); + }); + + test('frozen path reports the keys of modulesNotOnDisk', async () => { + writeDownloadedLock({ dependencies: { core: '2.0.0' }, devDependencies: {} }); + modulesNotOnDisk.mockReturnValueOnce({ core: '2.0.0' }); + + const result = await frozenInstall(spinner, { core: '^2.0.0' }, {}); + + expect(result.downloaded).toEqual(['core']); + }); +});