@ai-ecoverse/slicc-man 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ The same installers are attached to every GitHub release, as `install.sh` and `i
30
30
  curl -fsSL https://github.com/ai-ecoverse/slicc-man/releases/latest/download/install.sh | sh
31
31
  ```
32
32
 
33
- They install the release's `scman` with a launcher in `~/.local/bin` (on Windows `%LOCALAPPDATA%\slicc-man\bin`, added to the user's `PATH`), and download Node 24 into the install when no Node 24 or later is on `PATH`. Options pass through (`curl -fsSL https://get.sliccy.ai/man | sh -s -- --help`): `--version <version>` installs that release instead of the latest, `--dir <dir>` puts the launcher in `<dir>`, and `--help` prints the usage. `install.ps1` takes the same options.
33
+ They install the release's `scman` with a launcher in `~/.local/bin` (on Windows `%LOCALAPPDATA%\slicc-man\bin`, added to the user's `PATH`), and download Node 24 into the install when no Node 24 or later is on `PATH` (the `.tar.gz`, or the `.zip` on Windows), checked against nodejs.org's `SHASUMS256.txt`. Each release also carries the package tarball, `slicc-man-<version>.tgz`, and `SHA256SUMS`, which covers the tarball, `install.sh` and `install.ps1`: the installers check the tarball against it, unpack it into `${XDG_DATA_HOME:-~/.local/share}/slicc-man/<version>` (on Windows `%LOCALAPPDATA%\slicc-man\<version>`), write the launcher (`scman`, or `scman.ps1` and `scman.cmd` on Windows, which only start `node.exe` with the entry script and the arguments, the node call and its `exit` on one line of the `.cmd`), run it with `--version` (the version alone, or `scman <version>` from older releases), and print `scman <version> installed to <launcher>`. Options pass through (`curl -fsSL https://get.sliccy.ai/man | sh -s -- --help`): `--version <version>` installs that release instead of the latest, `--dir <dir>` puts the launcher in `<dir>`, and `--help` prints the usage; `install.sh` exits 2 for an unknown option or one without its value. `install.ps1` takes the same options, spelled `-Version`, `-Dir` and `-Help`. `SLICC_INSTALL_BASE_URL` replaces the directory the assets are downloaded from (by default `https://github.com/ai-ecoverse/slicc-man/releases/latest/download`, or `…/releases/download/v<version>` with `--version`), and `SLICC_NODE_DIST_BASE` nodejs.org, for mirrors and tests.
34
34
 
35
35
  On a machine with Node 24 or later, npm works too:
36
36
 
@@ -57,15 +57,15 @@ man [options] [section] topic...
57
57
  | `-a`, `--all` | show every matching page; there is only ever one, so it changes nothing |
58
58
  | `-w`, `--where`, `--path`, `--location` | print the page's URL, `https://www.sliccy.com/man/<topic>.md`, instead of showing it |
59
59
  | `-P <pager>`, `--pager=<pager>` | the pager (see [Rendering](#rendering)) |
60
- | `-l <file>`, `--local-file=<file>` | render a local Markdown file (`-` for stdin) as a page; the operands are files, not topics |
60
+ | `-l`, `--local-file` | render the operands, local Markdown files (`-` for stdin), as pages; the page's name is the file's, without `.md`, and `stdin` for `-` |
61
61
  | `-i`, `--ignore-case` | the default |
62
62
  | `-I`, `--match-case` | look the topic up as typed, not in lower case |
63
63
  | `--nh`, `--no-hyphenation`, `--nj`, `--no-justification` | accepted; the renderer neither hyphenates nor justifies |
64
64
  | `-h`, `--help` | the usage to stdout, exit 0 |
65
- | `-V`, `--version` | `man <version>` (or `scman <version>`) to stdout, exit 0; every host command takes it, and the installer runs the launcher with it |
65
+ | `-V`, `--version` | the version alone (`1.2.0`) to stdout, exit 0; every host command takes it, and the installer runs the launcher with it |
66
66
  | `--upgrade` | see [`--upgrade`](#--upgrade) |
67
67
 
68
- An option's value may be attached (`-s1`, `-Pcat`) or the next argument, and `--` ends the options. `man` with no topic prints `What manual page do you want?` and `For example, try 'man man'.` to stderr and exits 2.
68
+ Short options may be grouped (`-aw`). An option's value may be attached (`-s1`, `-Pcat`, `--pager=cat`) or the next argument; options may come after the operands, and `--` ends the options. Of `-k` and `-f`, the last one given counts, and so for `-i` and `-I`. With `-k` or `-f`, the operands are matched with `apropos`'s and `whatis`'s defaults, and only `-s` of the other options counts. `man` with no topic prints `What manual page do you want?` and `For example, try 'man man'.` to stderr and exits 2.
69
69
 
70
70
  ## apropos and whatis
71
71
 
@@ -87,7 +87,7 @@ whatis [options] topic...
87
87
  | `-s <list>`, `--sections=<list>` | only those sections; without 1, nothing matches | the same |
88
88
  | `-h`, `-V`, `--upgrade` | as for `man` | the same |
89
89
 
90
- On a terminal, a line wider than the width (see [Rendering](#rendering)) is cut and ends in `...`; through a pipe it is whole. A keyword with no match prints `<keyword>: nothing appropriate.` to stderr. With `-a`, that is printed for the keywords together, when no page matches them all. A regular expression that doesn't parse is a usage error.
90
+ On a terminal, a line wider than the width (see [Rendering](#rendering)) is cut and ends in `...`; through a pipe it is whole. A keyword with no match prints `<keyword>: nothing appropriate.` to stderr. With `-a`, that is printed for the keywords together, when no page matches them all. A regular expression that doesn't parse is a usage error, `<cmd>: <keyword>: invalid regular expression`. With no keyword, `apropos` prints `apropos what?` (`whatis`: `whatis what?`) to stderr and exits 2; `man -k` and `man -f` do the same.
91
91
 
92
92
  ## Not supported
93
93
 
@@ -96,7 +96,7 @@ These man-db options can't work here. They print `<cmd>: <option>: not supported
96
96
  - **man:** `-t`/`--troff`, `-T`/`--troff-device`, `-H`/`--html`, `-X`/`--gxditview`, `-Z`/`--ditroff`, `-M`/`--manpath`, `-m`/`--systems`, `-L`/`--locale`, `-C`/`--config-file`, `-d`/`--debug`, `-D`/`--default`, `-W`/`--where-cat`, `-c`/`--catman`, `-u`/`--update`, `-e`/`--extension`, `-p`/`--preprocessor`, `-R`/`--recode`, `-E`/`--encoding`, `-K`/`--global-apropos`, `-r`/`--prompt`, `--regex`, `--wildcard`, `--names-only`, `--no-subpages`.
97
97
  - **apropos, whatis:** `-M`/`--manpath`, `-m`/`--systems`, `-L`/`--locale`, `-C`/`--config-file`, `-d`/`--debug`, `-v`/`--verbose`.
98
98
 
99
- Any other option, or an option without its value, prints `<cmd>: unknown option: <option>` (or `<cmd>: <option>: needs a value`) and the usage to stderr, and exits 2.
99
+ Any other option, or an option without its value, prints `<cmd>: unknown option: <option>` (or `<cmd>: <option>: needs a value`) and the usage to stderr, and exits 2. So does `--upgrade` with anything else, as `<cmd>: --upgrade takes no other arguments`.
100
100
 
101
101
  ## Exit codes
102
102
 
@@ -132,7 +132,7 @@ Pages, the sitemap and the topic index are cached:
132
132
  - on a host in `$XDG_CACHE_HOME/slicc-man`, by default `~/.cache/slicc-man`, and on Windows in `%LOCALAPPDATA%\slicc-man\cache`;
133
133
  - in SLICC in `~/.cache/slicc-man`, which lives in OPFS, so it survives reloads.
134
134
 
135
- Each site has its own directory in the cache, named by the base URL's host and port. A cached copy is fresh for the response's `Cache-Control` `max-age` (the site sends 4 hours), and 4 hours without one. A stale copy is revalidated with `If-Modified-Since` (the response's `Last-Modified`): `304` keeps it and makes it fresh again, `200` replaces it, and `404` drops it, so the topic has no page. When the site can't be reached, or answers 5xx, a cached copy is shown however old, after one line on stderr, `<cmd>: <url>: <reason>; showing the copy cached <date>`. With nothing cached, the command prints `<cmd>: <url>: <reason>` and exits 2. The cache is written atomically (a temporary file renamed into place), so two commands at once never see half a page; a cache that can't be written is skipped without a word.
135
+ Each site has its own directory in the cache, named by the base URL's host and port (`www.sliccy.com`, `127.0.0.1-8080`), with one JSON file per page, `sitemap.json` and `index.json`. A cached copy is fresh for the response's `Cache-Control` `max-age` (the site sends 4 hours), and 4 hours without one. A stale copy is revalidated with `If-Modified-Since` (the response's `Last-Modified`): `304` keeps it and makes it fresh again, `200` replaces it, and `404` drops it, so the topic has no page. When the site can't be reached, or answers 5xx, a cached copy is shown however old, after one line on stderr, `<cmd>: <url>: <reason>; showing the copy cached <date>`. With nothing cached, the command prints `<cmd>: <url>: <reason>` and exits 2. The cache is written atomically (a temporary file renamed into place), so two commands at once never see half a page; a cache that can't be written is skipped without a word.
136
136
 
137
137
  `apropos` and `whatis` read the index: every `/man/<topic>` entry of the sitemap, with its description, the first paragraph of its page, rendered as plain text on one line. The index is built from the cached sitemap and pages, fetching what is missing or stale (pages through the same cache, several at a time), and cached itself under the same rules. A page that fails to load is listed without a description.
138
138
 
@@ -148,6 +148,8 @@ Every host command takes `--upgrade`. It runs the latest release's installer (`i
148
148
 
149
149
  The commands are `scman` on a host and `man`, `apropos`, `whatis` and `scman` in SLICC.
150
150
 
151
+ The launcher the installer writes sets `SCMAN_INSTALL_DIR` to its own directory; that is how `scman` knows the installer put it there (it removes the variable before it starts a pager or the installer), and without it `scman` counts as installed with npm. The installer is downloaded from `https://github.com/ai-ecoverse/slicc-man/releases/latest/download/` and run as `sh install.sh --dir DIR`, or `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File install.ps1 -Dir DIR` (else `pwsh.exe`); the line it prints, `scman <version> installed to <launcher>`, names the new version. A download that fails is the installer failing: `<cmd>: could not download <url>: <reason>`, exit 1. The download has 60 seconds; after that `--upgrade` prints `<cmd>: download timed out` to stderr and exits 1. `SLICC_INSTALL_BASE_URL`, when set, replaces that release URL for `--upgrade` and for the installers' own downloads.
152
+
151
153
  ## Platforms
152
154
 
153
155
  | platform | commands | cache | pager | network |
@@ -168,11 +170,13 @@ npm test
168
170
  npm run test:unit
169
171
  ```
170
172
 
171
- - `npm run lint`: Biome, the no-comment check and the other [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web) checks.
172
- - `npm test`: the integration tests in `test/integration/`. Host tests run the commands against a local HTTP server on an ephemeral port standing in for www.sliccy.com (`SLICC_MAN_URL`), with a temporary cache and fake tools, `less` among them, on a temporary `PATH`; only CI touches the real site. `test/integration/repo.test.mjs` checks the workflow, the release config, the package's shape, what `npm pack` ships and the repo rules.
173
+ - `npm run lint`: Biome, the no-comment check and the other [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web) checks, then `shellcheck -s sh` and `dash -n` on `install.sh`.
174
+ - `npm test`: the integration tests in `test/integration/`. `host.test.mjs` runs `scman` (in-process, so a terminal can be faked, and as `host/scman.mjs`) against a local HTTP server on an ephemeral port standing in for www.sliccy.com (`SLICC_MAN_URL`), serving copies of the site's `tree.md` and `man.md` (`pages.json`, with their expected renderings), with a temporary cache and fake tools, `less` among them, on a temporary `PATH`; only CI touches the real site. `install.test.mjs` runs `install.sh` on a tarball packed from the tree, with the Node on `PATH` and with a Node download from a fake nodejs.org, and `--upgrade` against a release served from a local server; `install.ps1` is checked statically, and run when `pwsh` is on `PATH`. `repo.test.mjs` checks the workflow, the release config, the package's shape, what `npm pack` ships and the repo rules.
173
175
  - `npm run test:unit`: the unit tests in the gitignored `test/unit/`, with Node's coverage in `coverage/unit/lcov.info`. The pre-commit hook wants 100% coverage of the changed lines.
174
176
 
175
- CI is `.github/workflows/ci.yml`, the only workflow, which runs [slicc-shared-web's pipeline](https://github.com/ai-ecoverse/slicc-shared-web#pipeline) at the devDependency's version, on Linux only: `npm run lint`, then `npm test`, then on a pull request the Claude and Codex reviews. The merge queue requires `ci / integration` and `ci / review`. On a push to `main`, semantic-release releases and publishes to npm through trusted publishing, with provenance from `ci.yml`.
177
+ CI is `.github/workflows/ci.yml`, the only workflow, which runs [slicc-shared-web's pipeline](https://github.com/ai-ecoverse/slicc-shared-web#pipeline) at the devDependency's version, on Linux only: `npm run lint`, then `npm test`, then on a pull request the Claude and Codex reviews. The merge queue requires `ci / integration` and `ci / review`. On a push to `main`, semantic-release releases and publishes to npm through trusted publishing, with provenance from `ci.yml`, and attaches `slicc-man-<version>.tgz`, `SHA256SUMS`, `install.sh` and `install.ps1` to the GitHub release (`scripts/assets.mjs` renames the tarball and writes `SHA256SUMS`).
178
+
179
+ The code is in `lib/` (options, the Markdown renderer, the cache and index, apropos and whatis, shared with SLICC) and `host/` (`scman.mjs`, the cache directory, the pager, `--upgrade`).
176
180
 
177
181
  ## License
178
182
 
package/host/cache.mjs ADDED
@@ -0,0 +1,43 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { variable } from './path.mjs';
5
+
6
+ export function cacheRoot(platform, env) {
7
+ if (platform === 'win32') {
8
+ const local = variable(env, 'LOCALAPPDATA') || join(homedir(), 'AppData', 'Local');
9
+ return join(local, 'slicc-man', 'cache');
10
+ }
11
+ const xdg = env.XDG_CACHE_HOME;
12
+ if (xdg && isAbsolute(xdg)) return join(xdg, 'slicc-man');
13
+ return join(env.HOME || homedir(), '.cache', 'slicc-man');
14
+ }
15
+
16
+ let counter = 0;
17
+
18
+ export function store(dir) {
19
+ const file = (id) => join(dir, `${id}.json`);
20
+ return {
21
+ async read(id) {
22
+ try {
23
+ return await readFile(file(id), 'utf8');
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ },
28
+ async write(id, text) {
29
+ await mkdir(dir, { recursive: true });
30
+ const temp = join(dir, `.${id}.${process.pid}.${Date.now()}.${counter++}.tmp`);
31
+ try {
32
+ await writeFile(temp, text);
33
+ await rename(temp, file(id));
34
+ } catch (error) {
35
+ await rm(temp, { force: true });
36
+ throw error;
37
+ }
38
+ },
39
+ async remove(id) {
40
+ await rm(file(id), { force: true });
41
+ },
42
+ };
43
+ }
package/host/io.mjs ADDED
@@ -0,0 +1,15 @@
1
+ import { writeSync } from 'node:fs';
2
+
3
+ export function say(fd, text) {
4
+ const data = Buffer.from(text);
5
+ let at = 0;
6
+ while (at < data.length) {
7
+ try {
8
+ at += writeSync(fd, data, at, data.length - at);
9
+ } catch (error) {
10
+ if (error.code === 'EAGAIN') continue;
11
+ if (error.code === 'EPIPE') return;
12
+ throw error;
13
+ }
14
+ }
15
+ }
package/host/main.mjs ADDED
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { run } from '../lib/man.mjs';
4
+ import { cacheRoot, store } from './cache.mjs';
5
+ import { say } from './io.mjs';
6
+ import { page } from './pager.mjs';
7
+ import { lookup } from './path.mjs';
8
+ import { upgrade, version } from './upgrade.mjs';
9
+
10
+ const reasons = {
11
+ ENOENT: 'No such file or directory',
12
+ EACCES: 'Permission denied',
13
+ EISDIR: 'Is a directory',
14
+ ENOTDIR: 'Not a directory',
15
+ };
16
+
17
+ function read(file, fds) {
18
+ try {
19
+ return readFileSync(file === '-' ? fds[0] : file, 'utf8');
20
+ } catch (error) {
21
+ throw new Error(reasons[error.code] ?? error.message);
22
+ }
23
+ }
24
+
25
+ export async function main({
26
+ name,
27
+ argv,
28
+ env,
29
+ platform,
30
+ fds,
31
+ tty,
32
+ columns,
33
+ fetch = globalThis.fetch,
34
+ now,
35
+ }) {
36
+ const child = { ...env };
37
+ delete child.SCMAN_INSTALL_DIR;
38
+ const root = cacheRoot(platform, env);
39
+ const io = {
40
+ tty,
41
+ columns,
42
+ fetch,
43
+ now,
44
+ version: version(),
45
+ out: (text) => say(fds[1], text),
46
+ err: (text) => say(fds[2], text),
47
+ store: (dir) => store(join(root, dir)),
48
+ which: (file) => lookup(file, platform, env) !== undefined,
49
+ page: (command, text) => page(command, text, { name, platform, env: child, fds }),
50
+ read: (file) => read(file, fds),
51
+ upgrade: () => upgrade({ name, env, platform, fds, fetch }),
52
+ };
53
+ return run({ kind: 'man', name, argv, env, io });
54
+ }
package/host/pager.mjs ADDED
@@ -0,0 +1,38 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { say } from './io.mjs';
3
+ import { variable } from './path.mjs';
4
+
5
+ export function shell(command, platform, env) {
6
+ if (platform === 'win32') {
7
+ return {
8
+ file: variable(env, 'COMSPEC') || 'cmd.exe',
9
+ args: ['/d', '/s', '/c', `"${command}"`],
10
+ options: { windowsVerbatimArguments: true },
11
+ };
12
+ }
13
+ return { file: '/bin/sh', args: ['-c', command], options: {} };
14
+ }
15
+
16
+ export function page(command, text, { name, platform, env, fds }) {
17
+ const run = shell(command, platform, env);
18
+ const ignore = () => {};
19
+ process.on('SIGINT', ignore);
20
+ return new Promise((done) => {
21
+ let failed = false;
22
+ const child = spawn(run.file, run.args, {
23
+ ...run.options,
24
+ env,
25
+ stdio: ['pipe', fds[1], fds[2]],
26
+ });
27
+ child.on('error', (error) => {
28
+ failed = true;
29
+ say(fds[2], `${name}: ${command}: ${error.message}\n`);
30
+ });
31
+ child.stdin.on('error', ignore);
32
+ child.stdin.end(text);
33
+ child.on('close', (code) => {
34
+ process.off('SIGINT', ignore);
35
+ done(failed || code === 126 || code === 127 ? 2 : 0);
36
+ });
37
+ });
38
+ }
package/host/path.mjs ADDED
@@ -0,0 +1,33 @@
1
+ import { accessSync, constants, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export function variable(env, name) {
5
+ const found = Object.keys(env).find((key) => key.toUpperCase() === name);
6
+ return found === undefined ? undefined : env[found];
7
+ }
8
+
9
+ export function lookup(file, platform, env) {
10
+ const windows = platform === 'win32';
11
+ const dirs = String((windows ? variable(env, 'PATH') : env.PATH) ?? '')
12
+ .split(windows ? ';' : ':')
13
+ .filter(Boolean);
14
+ const exts = windows
15
+ ? [
16
+ '',
17
+ ...String(variable(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD')
18
+ .split(';')
19
+ .filter(Boolean),
20
+ ]
21
+ : [''];
22
+ for (const dir of dirs) {
23
+ for (const ext of exts) {
24
+ const path = join(dir, file + ext);
25
+ try {
26
+ if (!statSync(path).isFile()) continue;
27
+ if (!windows) accessSync(path, constants.X_OK);
28
+ return path;
29
+ } catch {}
30
+ }
31
+ }
32
+ return undefined;
33
+ }
package/host/scman.mjs ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { isatty } from 'node:tty';
3
+ import { main } from './main.mjs';
4
+
5
+ const tty = isatty(1);
6
+ process.exitCode = await main({
7
+ name: 'scman',
8
+ argv: process.argv.slice(2),
9
+ env: process.env,
10
+ platform: process.platform,
11
+ fds: [0, 1, 2],
12
+ tty,
13
+ columns: tty ? process.stdout.columns : undefined,
14
+ });
@@ -0,0 +1,93 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { say } from './io.mjs';
6
+ import { lookup } from './path.mjs';
7
+
8
+ export const LATEST = 'https://github.com/ai-ecoverse/slicc-man/releases/latest/download';
9
+
10
+ export function version() {
11
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
+ }
13
+
14
+ function reason(error) {
15
+ return error.cause?.message ?? error.message;
16
+ }
17
+
18
+ function installer(platform, env, path, dir) {
19
+ if (platform === 'win32') {
20
+ const file = ['powershell.exe', 'pwsh.exe'].find((name) => lookup(name, platform, env));
21
+ const args = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', path];
22
+ return file && { file, tool: lookup(file, platform, env), args: [...args, '-Dir', dir] };
23
+ }
24
+ const tool = lookup('sh', platform, env);
25
+ return tool && { file: 'sh', tool, args: [path, '--dir', dir] };
26
+ }
27
+
28
+ function wait(file, args, options) {
29
+ return new Promise((done) => {
30
+ const out = [];
31
+ const child = spawn(file, args, options);
32
+ child.stdout.on('data', (chunk) => out.push(chunk));
33
+ child.on('error', (error) => done({ error }));
34
+ child.on('close', (code) => done({ code, stdout: Buffer.concat(out).toString() }));
35
+ });
36
+ }
37
+
38
+ export async function upgrade({ name, env, platform, fds, fetch }) {
39
+ const fail = (text) => {
40
+ say(fds[2], `${name}: ${text}\n`);
41
+ return 1;
42
+ };
43
+ const dir = env.SCMAN_INSTALL_DIR;
44
+ if (!dir) {
45
+ say(fds[1], 'npm install -g @ai-ecoverse/slicc-man@latest\n');
46
+ return 0;
47
+ }
48
+ const file = platform === 'win32' ? 'install.ps1' : 'install.sh';
49
+ const url = `${(env.SLICC_INSTALL_BASE_URL || LATEST).replace(/\/+$/, '')}/${file}`;
50
+ let text;
51
+ try {
52
+ const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(60000) });
53
+ if (!response.ok) {
54
+ await response.body?.cancel();
55
+ return fail(`could not download ${url}: HTTP ${response.status}`);
56
+ }
57
+ text = await response.text();
58
+ } catch (error) {
59
+ if (error?.name === 'TimeoutError') return fail('download timed out');
60
+ return fail(`could not download ${url}: ${reason(error)}`);
61
+ }
62
+ const scratch = mkdtempSync(join(tmpdir(), 'scman-upgrade-'));
63
+ try {
64
+ const path = join(scratch, file);
65
+ writeFileSync(path, text);
66
+ const run = installer(platform, env, path, dir);
67
+ if (!run) {
68
+ const tried = platform === 'win32' ? 'powershell.exe, pwsh.exe' : 'sh';
69
+ return fail(`no shell found to run ${file} (tried ${tried})`);
70
+ }
71
+ const child = { ...env };
72
+ delete child.SCMAN_INSTALL_DIR;
73
+ const result = await wait(run.tool, run.args, {
74
+ env: child,
75
+ stdio: ['ignore', 'pipe', fds[2]],
76
+ windowsHide: true,
77
+ });
78
+ if (result.error) return fail(`${run.file}: ${result.error.message}`);
79
+ if (result.code !== 0) return 1;
80
+ const installed = /^scman (\S+) installed to /m.exec(result.stdout)?.[1];
81
+ if (!installed) return fail(`${file} did not say which version it installed`);
82
+ const old = version();
83
+ say(
84
+ fds[1],
85
+ installed === old
86
+ ? `${name}: already up to date (${old})\n`
87
+ : `${name}: ${old} → ${installed}\n`
88
+ );
89
+ return 0;
90
+ } finally {
91
+ rmSync(scratch, { recursive: true, force: true });
92
+ }
93
+ }
package/lib/man.mjs ADDED
@@ -0,0 +1,215 @@
1
+ import { clean } from './markdown.mjs';
2
+ import { parse, usage } from './options.mjs';
3
+ import { render } from './render.mjs';
4
+ import { cut, line, matcher, search } from './search.mjs';
5
+ import { base, folder, site } from './site.mjs';
6
+
7
+ const OK = 0;
8
+ const USAGE = 2;
9
+ const NONE = 16;
10
+
11
+ function worse(a, b) {
12
+ if (a === USAGE || b === USAGE) return USAGE;
13
+ return a === NONE || b === NONE ? NONE : OK;
14
+ }
15
+
16
+ function width(env, io) {
17
+ const manwidth = String(env.MANWIDTH ?? '').trim();
18
+ if (/^\d+$/.test(manwidth) && Number(manwidth) > 0) return Number(manwidth);
19
+ if (io.tty && Number.isInteger(io.columns) && io.columns > 0) return io.columns;
20
+ return 80;
21
+ }
22
+
23
+ function sections(text) {
24
+ return text.split(/[:,]/).filter(Boolean);
25
+ }
26
+
27
+ const sectionName = /^[0-9][A-Za-z]*$/;
28
+
29
+ async function show(text, ctx) {
30
+ const { io, env, values } = ctx;
31
+ if (!io.tty) {
32
+ await io.out(text);
33
+ return OK;
34
+ }
35
+ let pager = values.pager ?? env.MANPAGER ?? env.PAGER;
36
+ if (pager === undefined) pager = (await io.which('less')) ? 'less -R' : '';
37
+ if (pager.trim() === '') {
38
+ await io.out(text);
39
+ return OK;
40
+ }
41
+ return io.page(pager, text);
42
+ }
43
+
44
+ function choose(kind, flags) {
45
+ if (flags.regex) return 'regex';
46
+ if (flags.wildcard) return 'wildcard';
47
+ if (flags.exact || kind === 'whatis') return 'exact';
48
+ return 'regex';
49
+ }
50
+
51
+ async function compile(kind, ctx, flags, operands) {
52
+ const mode = choose(kind, flags);
53
+ const matchers = [];
54
+ for (const keyword of operands) {
55
+ try {
56
+ matchers.push(matcher(kind, mode, keyword));
57
+ } catch {
58
+ await ctx.io.err(`${ctx.name}: ${keyword}: invalid regular expression\n`);
59
+ return undefined;
60
+ }
61
+ }
62
+ return matchers;
63
+ }
64
+
65
+ async function keywords(kind, ctx, parsed) {
66
+ const { io } = ctx;
67
+ const { flags, values, operands } = parsed;
68
+ if (operands.length === 0) {
69
+ await io.err(`${kind} what?\n`);
70
+ return USAGE;
71
+ }
72
+ const matchers = await compile(kind, ctx, flags, operands);
73
+ if (!matchers) return USAGE;
74
+ const all = Boolean(flags.and);
75
+ const nothing = async () => {
76
+ if (all) await io.err(`${operands.join(' ')}: nothing appropriate.\n`);
77
+ else for (const keyword of operands) await io.err(`${keyword}: nothing appropriate.\n`);
78
+ return NONE;
79
+ };
80
+ if (values.sections !== undefined && !sections(values.sections).includes('1')) return nothing();
81
+ const index = await ctx.site.index();
82
+ if (index.note) await io.err(`${index.note}\n`);
83
+ if (index.error) {
84
+ await io.err(`${index.error}\n`);
85
+ return USAGE;
86
+ }
87
+ const { found, hits } = search(index.entries, matchers, all);
88
+ const columns = width(ctx.env, io);
89
+ const long = flags.long || !io.tty;
90
+ let out = '';
91
+ for (const entry of found) {
92
+ const text = clean(line(entry));
93
+ out += `${long ? text : cut(text, columns)}\n`;
94
+ }
95
+ if (out) await io.out(out);
96
+ if (all) return found.length === 0 ? nothing() : OK;
97
+ let code = OK;
98
+ for (const [n, keyword] of operands.entries()) {
99
+ if (!hits[n]) {
100
+ await io.err(`${keyword}: nothing appropriate.\n`);
101
+ code = NONE;
102
+ }
103
+ }
104
+ return code;
105
+ }
106
+
107
+ function title(file) {
108
+ if (file === '-') return 'stdin';
109
+ const leaf = file.split(/[/\\]/).pop() || file;
110
+ return clean(leaf.replace(/\.(md|markdown)$/i, '')) || 'stdin';
111
+ }
112
+
113
+ async function local(ctx, files) {
114
+ const { io, name } = ctx;
115
+ let code = OK;
116
+ for (const file of files) {
117
+ let text;
118
+ try {
119
+ text = await io.read(file);
120
+ } catch (error) {
121
+ await io.err(`${name}: ${clean(file)}: ${error.message}\n`);
122
+ code = worse(code, USAGE);
123
+ continue;
124
+ }
125
+ const page = render(text, { topic: title(file), width: ctx.width, styled: ctx.styled });
126
+ const shown = await show(page, ctx);
127
+ if (shown !== OK) return worse(code, shown);
128
+ }
129
+ return code;
130
+ }
131
+
132
+ async function pages(ctx, parsed) {
133
+ const { io, flags, values } = ctx;
134
+ let operands = parsed.operands;
135
+ let given = values.sections;
136
+ if (given === undefined && operands.length >= 2 && sectionName.test(operands[0])) {
137
+ given = operands[0];
138
+ operands = operands.slice(1);
139
+ }
140
+ let code = OK;
141
+ for (const operand of operands) {
142
+ let topic = operand;
143
+ let section = given;
144
+ const suffix = /^(.+)\(([0-9][A-Za-z]*)\)$/.exec(operand);
145
+ if (suffix) {
146
+ topic = suffix[1];
147
+ section = suffix[2];
148
+ }
149
+ const shown = clean(topic);
150
+ const where = section === undefined ? '' : ` in section ${clean(section)}`;
151
+ if (section !== undefined && !sections(section).includes('1')) {
152
+ await io.err(`No manual entry for ${shown}${where}\n`);
153
+ code = worse(code, NONE);
154
+ continue;
155
+ }
156
+ const wanted = flags.match ? topic : topic.toLowerCase();
157
+ const found = await ctx.site.page(wanted);
158
+ if (found.note) await io.err(`${found.note}\n`);
159
+ if (found.status === 'error') {
160
+ await io.err(`${found.message}\n`);
161
+ code = worse(code, USAGE);
162
+ continue;
163
+ }
164
+ if (found.status === 'missing') {
165
+ await io.err(`No manual entry for ${shown}${where}\n`);
166
+ code = worse(code, NONE);
167
+ continue;
168
+ }
169
+ if (flags.where) {
170
+ await io.out(`${ctx.site.address(wanted)}\n`);
171
+ continue;
172
+ }
173
+ const page = render(found.body, { topic: wanted, width: ctx.width, styled: ctx.styled });
174
+ const paged = await show(page, ctx);
175
+ if (paged !== OK) return worse(code, paged);
176
+ }
177
+ return code;
178
+ }
179
+
180
+ export async function run({ kind = 'man', name, argv, env, io }) {
181
+ const parsed = parse(kind, argv);
182
+ if (parsed.error) {
183
+ await io.err(`${name}: ${parsed.error}\n${parsed.usage ? usage(kind, name) : ''}`);
184
+ return USAGE;
185
+ }
186
+ if (parsed.help) {
187
+ await io.out(usage(kind, name));
188
+ return OK;
189
+ }
190
+ if (parsed.version) {
191
+ await io.out(`${io.version}\n`);
192
+ return OK;
193
+ }
194
+ if (parsed.flags.upgrade) return io.upgrade();
195
+ const url = base(env);
196
+ const ctx = {
197
+ io,
198
+ env,
199
+ name,
200
+ flags: parsed.flags,
201
+ values: parsed.values,
202
+ width: width(env, io),
203
+ styled: Boolean(io.tty) && !env.NO_COLOR,
204
+ site: site({ url, store: io.store(folder(url)), fetch: io.fetch, now: io.now, name }),
205
+ };
206
+ if (kind !== 'man') return keywords(kind, ctx, parsed);
207
+ if (parsed.flags.apropos) return keywords('apropos', ctx, parsed);
208
+ if (parsed.flags.whatis) return keywords('whatis', ctx, parsed);
209
+ if (parsed.operands.length === 0) {
210
+ await io.err("What manual page do you want?\nFor example, try 'man man'.\n");
211
+ return USAGE;
212
+ }
213
+ if (parsed.flags.local) return local(ctx, parsed.operands);
214
+ return pages(ctx, parsed);
215
+ }