Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --dev` adds the module to `devDependencies`; without a name, `--dev` includes both sections in the resolution.
* `pos-cli modules show <module-name>` — display all available versions of a module from the registry.
* `pos-cli modules show <module-name>` — 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 <module-name>` — 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:
Expand Down
26 changes: 26 additions & 0 deletions bin/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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/<name>/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 <name>` (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 <name>` (when the module is installed locally).
4 changes: 1 addition & 3 deletions lib/modules/downloadModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions lib/modules/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -40,16 +41,19 @@ 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 });
}

let prodMods = prodModules;
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) {
Expand All @@ -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
Expand All @@ -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 };
4 changes: 2 additions & 2 deletions lib/modules/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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' };
};

/**
Expand Down
7 changes: 7 additions & 0 deletions lib/modules/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`. */
export const getModulePath = (name) => path.join(getModulesDir(), name);
137 changes: 137 additions & 0 deletions lib/modules/postInstall.js
Original file line number Diff line number Diff line change
@@ -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/<name>/pos-module.json (short notes)
* 2. modules/<name>/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/<name>/ — 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 };
5 changes: 5 additions & 0 deletions lib/modules/show.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 };
6 changes: 1 addition & 5 deletions lib/modules/uninstall.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
21 changes: 11 additions & 10 deletions lib/watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading