@ai-ecoverse/slicc-man 0.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/LICENSE +201 -0
- package/README.md +179 -5
- package/host/cache.mjs +43 -0
- package/host/io.mjs +15 -0
- package/host/main.mjs +54 -0
- package/host/pager.mjs +38 -0
- package/host/path.mjs +33 -0
- package/host/scman.mjs +14 -0
- package/host/upgrade.mjs +93 -0
- package/lib/man.mjs +215 -0
- package/lib/markdown.mjs +456 -0
- package/lib/options.mjs +192 -0
- package/lib/render.mjs +214 -0
- package/lib/search.mjs +83 -0
- package/lib/site.mjs +205 -0
- package/package.json +47 -1
package/host/upgrade.mjs
ADDED
|
@@ -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
|
+
}
|