@liiift-studio/vf-clamp-cli 0.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 +21 -0
- package/README.md +194 -0
- package/dist/commands/clamp.d.ts +9 -0
- package/dist/commands/clamp.js +202 -0
- package/dist/commands/instances.d.ts +6 -0
- package/dist/commands/instances.js +89 -0
- package/dist/core/axisSpecs.d.ts +15 -0
- package/dist/core/axisSpecs.js +85 -0
- package/dist/core/config.d.ts +35 -0
- package/dist/core/config.js +133 -0
- package/dist/core/exitCodes.d.ts +15 -0
- package/dist/core/exitCodes.js +47 -0
- package/dist/core/format.d.ts +25 -0
- package/dist/core/format.js +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +66 -0
- package/dist/utils/font.d.ts +62 -0
- package/dist/utils/font.js +196 -0
- package/dist/utils/format.d.ts +14 -0
- package/dist/utils/format.js +36 -0
- package/package.json +44 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// JSON config-file parsing and validation — pure logic, no IO besides reading
|
|
2
|
+
// the file from disk via the injected reader. Strict validation is performed
|
|
3
|
+
// here so the CLI emits clean errors rather than letting malformed configs
|
|
4
|
+
// crash inside Pyodide with cryptic Python tracebacks.
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { assertFormat } from './format.js';
|
|
8
|
+
/**
|
|
9
|
+
* Reads and parses a JSON config file from disk, then validates every field
|
|
10
|
+
* before returning a typed object. Throws a descriptive error if the file
|
|
11
|
+
* cannot be read, is not valid JSON, or contains invalid fields.
|
|
12
|
+
*
|
|
13
|
+
* Error messages echo the user-supplied path (not the resolved absolute path)
|
|
14
|
+
* to avoid leaking server-side filesystem layout when invoked from services.
|
|
15
|
+
*/
|
|
16
|
+
export async function readConfig(configPath) {
|
|
17
|
+
const resolved = path.resolve(configPath);
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = await fs.readFile(resolved, 'utf-8');
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
24
|
+
throw new Error(`Could not read config file "${configPath}": ${message}`);
|
|
25
|
+
}
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`Config file "${configPath}" is not valid JSON`);
|
|
32
|
+
}
|
|
33
|
+
return validateConfig(parsed);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validates a parsed JSON value against the ClampConfig schema.
|
|
37
|
+
* Exported separately so tests can exercise validation without disk I/O.
|
|
38
|
+
*/
|
|
39
|
+
export function validateConfig(input) {
|
|
40
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
41
|
+
throw new Error('Config file must contain a JSON object at the top level');
|
|
42
|
+
}
|
|
43
|
+
const raw = input;
|
|
44
|
+
const result = { outputs: [] };
|
|
45
|
+
if (raw.format !== undefined) {
|
|
46
|
+
if (typeof raw.format !== 'string') {
|
|
47
|
+
throw new Error('Config "format" must be a string');
|
|
48
|
+
}
|
|
49
|
+
assertFormat(raw.format);
|
|
50
|
+
result.format = raw.format;
|
|
51
|
+
}
|
|
52
|
+
if (raw.outputDir !== undefined) {
|
|
53
|
+
if (typeof raw.outputDir !== 'string') {
|
|
54
|
+
throw new Error('Config "outputDir" must be a string');
|
|
55
|
+
}
|
|
56
|
+
result.outputDir = raw.outputDir;
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(raw.outputs) || raw.outputs.length === 0) {
|
|
59
|
+
throw new Error('Config file must contain a non-empty "outputs" array');
|
|
60
|
+
}
|
|
61
|
+
result.outputs = raw.outputs.map((entry, idx) => validateOutput(entry, idx));
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
/** Validates a single output entry. */
|
|
65
|
+
function validateOutput(entry, idx) {
|
|
66
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
67
|
+
throw new Error(`Output at index ${idx} must be an object`);
|
|
68
|
+
}
|
|
69
|
+
const raw = entry;
|
|
70
|
+
const out = {};
|
|
71
|
+
if (raw.name !== undefined) {
|
|
72
|
+
if (typeof raw.name !== 'string') {
|
|
73
|
+
throw new Error(`Output at index ${idx}: "name" must be a string`);
|
|
74
|
+
}
|
|
75
|
+
out.name = raw.name;
|
|
76
|
+
}
|
|
77
|
+
if (raw.instances !== undefined) {
|
|
78
|
+
if (!Array.isArray(raw.instances) ||
|
|
79
|
+
!raw.instances.every((v) => typeof v === 'string')) {
|
|
80
|
+
throw new Error(`Output at index ${idx}: "instances" must be an array of strings`);
|
|
81
|
+
}
|
|
82
|
+
out.instances = raw.instances;
|
|
83
|
+
}
|
|
84
|
+
if (raw.axes !== undefined) {
|
|
85
|
+
out.axes = validateAxes(raw.axes, idx);
|
|
86
|
+
}
|
|
87
|
+
if (!out.instances?.length && !out.axes) {
|
|
88
|
+
throw new Error(`Output at index ${idx} must have "instances" or non-empty "axes"`);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** Validates the axes map for a single output entry. */
|
|
93
|
+
function validateAxes(value, idx) {
|
|
94
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
95
|
+
throw new Error(`Output at index ${idx}: "axes" must be an object`);
|
|
96
|
+
}
|
|
97
|
+
const raw = value;
|
|
98
|
+
const tags = Object.keys(raw);
|
|
99
|
+
if (tags.length === 0) {
|
|
100
|
+
throw new Error(`Output at index ${idx}: "axes" must have at least one entry`);
|
|
101
|
+
}
|
|
102
|
+
const axes = {};
|
|
103
|
+
for (const tag of tags) {
|
|
104
|
+
const v = raw[tag];
|
|
105
|
+
if (v === null) {
|
|
106
|
+
axes[tag] = null;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (typeof v === 'number') {
|
|
110
|
+
if (!Number.isFinite(v)) {
|
|
111
|
+
throw new Error(`Output at index ${idx}, axis "${tag}": pin value must be finite`);
|
|
112
|
+
}
|
|
113
|
+
axes[tag] = v;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (typeof v === 'object' && !Array.isArray(v)) {
|
|
117
|
+
const obj = v;
|
|
118
|
+
if (typeof obj.min !== 'number' || typeof obj.max !== 'number') {
|
|
119
|
+
throw new Error(`Output at index ${idx}, axis "${tag}": range must be { min: number, max: number }`);
|
|
120
|
+
}
|
|
121
|
+
if (!Number.isFinite(obj.min) || !Number.isFinite(obj.max)) {
|
|
122
|
+
throw new Error(`Output at index ${idx}, axis "${tag}": range min/max must be finite numbers`);
|
|
123
|
+
}
|
|
124
|
+
if (obj.min > obj.max) {
|
|
125
|
+
throw new Error(`Output at index ${idx}, axis "${tag}": min (${obj.min}) must not exceed max (${obj.max})`);
|
|
126
|
+
}
|
|
127
|
+
axes[tag] = { min: obj.min, max: obj.max };
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`Output at index ${idx}, axis "${tag}": must be number, { min, max }, or null`);
|
|
131
|
+
}
|
|
132
|
+
return axes;
|
|
133
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Usage error — bad flags, missing arguments. */
|
|
2
|
+
export declare const EX_USAGE = 2;
|
|
3
|
+
/** Input data error — malformed font / JSON config / axis spec. */
|
|
4
|
+
export declare const EX_DATAERR = 65;
|
|
5
|
+
/** Input file missing or unreadable. */
|
|
6
|
+
export declare const EX_NOINPUT = 66;
|
|
7
|
+
/** Internal software error — engine crash. */
|
|
8
|
+
export declare const EX_SOFTWARE = 70;
|
|
9
|
+
/** Invalid configuration. */
|
|
10
|
+
export declare const EX_CONFIG = 78;
|
|
11
|
+
/**
|
|
12
|
+
* Classifies an unknown error into a sysexits-style exit code based on
|
|
13
|
+
* either an explicit `code` tag on the error or pattern-matching the message.
|
|
14
|
+
*/
|
|
15
|
+
export declare function classifyError(err: unknown): number;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// BSD-sysexits-style exit codes, plus a heuristic classifier that maps
|
|
2
|
+
// thrown errors to the most appropriate code. Lets callers `if [ $? = 65 ]`
|
|
3
|
+
// react to specific failure modes rather than a flat exit 1 for everything.
|
|
4
|
+
/** Usage error — bad flags, missing arguments. */
|
|
5
|
+
export const EX_USAGE = 2;
|
|
6
|
+
/** Input data error — malformed font / JSON config / axis spec. */
|
|
7
|
+
export const EX_DATAERR = 65;
|
|
8
|
+
/** Input file missing or unreadable. */
|
|
9
|
+
export const EX_NOINPUT = 66;
|
|
10
|
+
/** Internal software error — engine crash. */
|
|
11
|
+
export const EX_SOFTWARE = 70;
|
|
12
|
+
/** Invalid configuration. */
|
|
13
|
+
export const EX_CONFIG = 78;
|
|
14
|
+
/**
|
|
15
|
+
* Classifies an unknown error into a sysexits-style exit code based on
|
|
16
|
+
* either an explicit `code` tag on the error or pattern-matching the message.
|
|
17
|
+
*/
|
|
18
|
+
export function classifyError(err) {
|
|
19
|
+
if (!(err instanceof Error))
|
|
20
|
+
return 1;
|
|
21
|
+
const tag = err.code;
|
|
22
|
+
if (tag === 'USAGE')
|
|
23
|
+
return EX_USAGE;
|
|
24
|
+
if (tag === 'ENOENT')
|
|
25
|
+
return EX_NOINPUT;
|
|
26
|
+
const msg = err.message.toLowerCase();
|
|
27
|
+
// More-specific patterns first.
|
|
28
|
+
if (msg.includes('not valid json'))
|
|
29
|
+
return EX_DATAERR;
|
|
30
|
+
if (msg.includes('engine returned unsupported format'))
|
|
31
|
+
return EX_SOFTWARE;
|
|
32
|
+
if (msg.startsWith('could not read') || msg.includes('is not a regular file'))
|
|
33
|
+
return EX_NOINPUT;
|
|
34
|
+
if (msg.startsWith('invalid --axis') || msg.startsWith('unsupported format'))
|
|
35
|
+
return EX_USAGE;
|
|
36
|
+
if (msg.startsWith('provide at least one'))
|
|
37
|
+
return EX_USAGE;
|
|
38
|
+
if (msg.includes('unrecognised font extension'))
|
|
39
|
+
return EX_USAGE;
|
|
40
|
+
if (msg.includes('exceeds the') && msg.includes('size cap'))
|
|
41
|
+
return EX_DATAERR;
|
|
42
|
+
if (msg.includes('refusing to'))
|
|
43
|
+
return EX_USAGE;
|
|
44
|
+
if (msg.startsWith('config') || msg.startsWith('output at index') || msg.includes(': "axes"'))
|
|
45
|
+
return EX_CONFIG;
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical set of output formats vf-clamp can produce.
|
|
3
|
+
* Kept in lockstep with `OutputFormat` from `@liiift-studio/vf-clamp`; if the
|
|
4
|
+
* engine ever gains/drops a format, change this list and the TS type below.
|
|
5
|
+
* The compile-time `satisfies OutputFormat` check below catches drift.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SUPPORTED_FORMATS: readonly ["ttf", "otf", "woff", "woff2"];
|
|
8
|
+
/** Union of supported format strings — derived from `SUPPORTED_FORMATS`. */
|
|
9
|
+
export type Format = (typeof SUPPORTED_FORMATS)[number];
|
|
10
|
+
/**
|
|
11
|
+
* The set of input font extensions the CLI will accept.
|
|
12
|
+
* The leading dot is mandatory for `path.extname` comparison.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SUPPORTED_INPUT_EXTENSIONS: readonly [".ttf", ".otf", ".woff", ".woff2"];
|
|
15
|
+
/**
|
|
16
|
+
* Type-predicate version of format validation — narrows `format` to `Format`
|
|
17
|
+
* on success and throws a descriptive error on failure. Using `asserts` lets
|
|
18
|
+
* call sites drop the `as Format` cast at the engine boundary.
|
|
19
|
+
*/
|
|
20
|
+
export declare function assertFormat(format: string): asserts format is Format;
|
|
21
|
+
/**
|
|
22
|
+
* Pure predicate variant — true if the string is a supported format.
|
|
23
|
+
* Useful when you want to test without throwing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isSupportedFormat(format: string): format is Format;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Output format constants, type predicate, and validators shared between the CLI
|
|
2
|
+
// surface and the engine call site.
|
|
3
|
+
/**
|
|
4
|
+
* The canonical set of output formats vf-clamp can produce.
|
|
5
|
+
* Kept in lockstep with `OutputFormat` from `@liiift-studio/vf-clamp`; if the
|
|
6
|
+
* engine ever gains/drops a format, change this list and the TS type below.
|
|
7
|
+
* The compile-time `satisfies OutputFormat` check below catches drift.
|
|
8
|
+
*/
|
|
9
|
+
export const SUPPORTED_FORMATS = ['ttf', 'otf', 'woff', 'woff2'];
|
|
10
|
+
/**
|
|
11
|
+
* The set of input font extensions the CLI will accept.
|
|
12
|
+
* The leading dot is mandatory for `path.extname` comparison.
|
|
13
|
+
*/
|
|
14
|
+
export const SUPPORTED_INPUT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2'];
|
|
15
|
+
/**
|
|
16
|
+
* Type-predicate version of format validation — narrows `format` to `Format`
|
|
17
|
+
* on success and throws a descriptive error on failure. Using `asserts` lets
|
|
18
|
+
* call sites drop the `as Format` cast at the engine boundary.
|
|
19
|
+
*/
|
|
20
|
+
export function assertFormat(format) {
|
|
21
|
+
if (!SUPPORTED_FORMATS.includes(format)) {
|
|
22
|
+
throw new Error(`Unsupported format "${format}". Choose from: ${SUPPORTED_FORMATS.join(', ')}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pure predicate variant — true if the string is a supported format.
|
|
27
|
+
* Useful when you want to test without throwing.
|
|
28
|
+
*/
|
|
29
|
+
export function isSupportedFormat(format) {
|
|
30
|
+
return SUPPORTED_FORMATS.includes(format);
|
|
31
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI entry point — sets up the vf-clamp program, registers all subcommands,
|
|
3
|
+
// installs signal handlers, and drives commander's async parser.
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { program } from 'commander';
|
|
6
|
+
import { registerInstancesCommand } from './commands/instances.js';
|
|
7
|
+
import { registerClampCommand } from './commands/clamp.js';
|
|
8
|
+
/**
|
|
9
|
+
* Read the package version at runtime from package.json so it never drifts
|
|
10
|
+
* from the declared version. `createRequire` is used because this is an ESM
|
|
11
|
+
* module and `import.meta.url` is available in the Node.js ESM context.
|
|
12
|
+
* Falls back to a literal `'0.0.0'` if the file cannot be read (e.g. when the
|
|
13
|
+
* package is bundled in a non-standard layout).
|
|
14
|
+
*/
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
function readVersion() {
|
|
17
|
+
try {
|
|
18
|
+
const pkg = require('../package.json');
|
|
19
|
+
if (pkg !== null &&
|
|
20
|
+
typeof pkg === 'object' &&
|
|
21
|
+
'version' in pkg &&
|
|
22
|
+
typeof pkg.version === 'string') {
|
|
23
|
+
return pkg.version;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// ignore — fall through to default
|
|
28
|
+
}
|
|
29
|
+
return '0.0.0';
|
|
30
|
+
}
|
|
31
|
+
const VERSION = readVersion();
|
|
32
|
+
// Clean exit on Ctrl-C / SIGTERM with conventional exit code 130
|
|
33
|
+
// (128 + SIGINT(2)) and a brief stderr notice. Without this, partially
|
|
34
|
+
// written outputs are an even bigger risk during long Pyodide runs.
|
|
35
|
+
function installSignalHandlers() {
|
|
36
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
37
|
+
process.on(signal, () => {
|
|
38
|
+
process.stderr.write(`\nvf-clamp: aborted on ${signal}\n`);
|
|
39
|
+
// 128 + signal number — bash convention for terminated-by-signal.
|
|
40
|
+
const code = signal === 'SIGINT' ? 130 : 143;
|
|
41
|
+
process.exit(code);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
installSignalHandlers();
|
|
46
|
+
program
|
|
47
|
+
.name('vf-clamp')
|
|
48
|
+
.description('Restrict variable font axis ranges from the command line.\n' +
|
|
49
|
+
'Note: first run in a fresh process takes 10–20s while the engine initialises.')
|
|
50
|
+
.version(VERSION, '-v, --version', 'Print version number')
|
|
51
|
+
.showHelpAfterError('(run vf-clamp --help for usage)');
|
|
52
|
+
registerInstancesCommand(program);
|
|
53
|
+
registerClampCommand(program);
|
|
54
|
+
// Use parseAsync so unhandled rejections in async action handlers surface
|
|
55
|
+
// cleanly and so we control the final exit code.
|
|
56
|
+
program.parseAsync(process.argv).then(() => {
|
|
57
|
+
// Explicit exit — Pyodide holds a large WASM heap and a watchdog timer
|
|
58
|
+
// that can keep the event loop alive long after work is done. Honour
|
|
59
|
+
// any exit code already set by an action handler.
|
|
60
|
+
process.exit(process.exitCode ?? 0);
|
|
61
|
+
}, (err) => {
|
|
62
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
process.stderr.write(`vf-clamp: ${message}\n`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Maximum input font file size we will read into memory (in bytes). */
|
|
2
|
+
export declare const MAX_FONT_BYTES: number;
|
|
3
|
+
/** Maximum bytes we will accept from stdin. */
|
|
4
|
+
export declare const MAX_STDIN_BYTES: number;
|
|
5
|
+
/**
|
|
6
|
+
* Reads a font file from disk and returns its contents as a tight `Uint8Array`.
|
|
7
|
+
*
|
|
8
|
+
* - Rejects files larger than `MAX_FONT_BYTES` to avoid OOM.
|
|
9
|
+
* - Returns a non-pooled `Uint8Array` (not a Node `Buffer`) so callers passing
|
|
10
|
+
* the bytes across the WASM boundary do not risk pool aliasing.
|
|
11
|
+
* - Error messages echo the user-supplied path, not the resolved absolute
|
|
12
|
+
* path, to avoid leaking server-side layout when invoked from services.
|
|
13
|
+
*/
|
|
14
|
+
export declare function readFontFile(filePath: string): Promise<Uint8Array>;
|
|
15
|
+
/** Options accepted by writeOutputs. */
|
|
16
|
+
export interface WriteOptions {
|
|
17
|
+
/** If false, throw rather than overwrite existing files. Defaults to true. */
|
|
18
|
+
force?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Writes one or more clamped font output buffers to disk.
|
|
22
|
+
*
|
|
23
|
+
* - Validates each entry's format string against the supported set so a
|
|
24
|
+
* misbehaving engine cannot inject `/`, `..`, or null bytes into the
|
|
25
|
+
* filename via the extension.
|
|
26
|
+
* - Computes the destination path inside `outputDir` and rejects any path
|
|
27
|
+
* that escapes the resolved output directory (defence against
|
|
28
|
+
* `..`/absolute-path injection from configs).
|
|
29
|
+
* - Writes each output atomically: writes to `<dest>.tmp-<pid>-<rand>` and
|
|
30
|
+
* renames into place, so a crash never leaves a half-written font.
|
|
31
|
+
* - Writes are issued in parallel via `Promise.all`.
|
|
32
|
+
* - Refuses to overwrite an existing file unless `options.force` is true.
|
|
33
|
+
*
|
|
34
|
+
* Returns the list of file paths written.
|
|
35
|
+
*/
|
|
36
|
+
export declare function writeOutputs(results: Array<{
|
|
37
|
+
name: string;
|
|
38
|
+
buffer: Uint8Array;
|
|
39
|
+
format: string;
|
|
40
|
+
}>, outputDir: string, options?: WriteOptions): Promise<string[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Validates that a font file path has a recognised variable-font extension.
|
|
43
|
+
* Skips the check for `-` (stdin input).
|
|
44
|
+
* Throws if the extension is not recognised.
|
|
45
|
+
*/
|
|
46
|
+
export declare function assertFontExtension(filePath: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* Sanitizes a string for safe use as a filename stem.
|
|
49
|
+
*
|
|
50
|
+
* Defends against:
|
|
51
|
+
* - path separators (`/`, `\`) being interpreted as directories
|
|
52
|
+
* - null bytes terminating C-string filename APIs
|
|
53
|
+
* - shell-special characters (`:*?"<>|`) on Windows
|
|
54
|
+
* - ASCII control characters (`\0`–`\x1F`, `\x7F`) and Unicode whitespace
|
|
55
|
+
* - embedded `..` segments that would resolve to a parent directory
|
|
56
|
+
* - Windows reserved device names (CON, PRN, AUX, NUL, COM1–9, LPT1–9)
|
|
57
|
+
* - names that are only dots or hyphens (would become empty after trim)
|
|
58
|
+
* - filenames longer than 200 characters
|
|
59
|
+
*
|
|
60
|
+
* Falls back to `"output"` when sanitisation reduces the string to nothing.
|
|
61
|
+
*/
|
|
62
|
+
export declare function sanitizeFilename(name: string): string;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// File I/O helpers for reading font files and writing clamped outputs.
|
|
2
|
+
// All filesystem boundaries live in this module; pure logic lives under src/core.
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { SUPPORTED_INPUT_EXTENSIONS } from '../core/format.js';
|
|
6
|
+
import { isSupportedFormat } from '../core/format.js';
|
|
7
|
+
/** Maximum input font file size we will read into memory (in bytes). */
|
|
8
|
+
export const MAX_FONT_BYTES = 256 * 1024 * 1024; // 256MB
|
|
9
|
+
/** Maximum bytes we will accept from stdin. */
|
|
10
|
+
export const MAX_STDIN_BYTES = MAX_FONT_BYTES;
|
|
11
|
+
/**
|
|
12
|
+
* Reads a font file from disk and returns its contents as a tight `Uint8Array`.
|
|
13
|
+
*
|
|
14
|
+
* - Rejects files larger than `MAX_FONT_BYTES` to avoid OOM.
|
|
15
|
+
* - Returns a non-pooled `Uint8Array` (not a Node `Buffer`) so callers passing
|
|
16
|
+
* the bytes across the WASM boundary do not risk pool aliasing.
|
|
17
|
+
* - Error messages echo the user-supplied path, not the resolved absolute
|
|
18
|
+
* path, to avoid leaking server-side layout when invoked from services.
|
|
19
|
+
*/
|
|
20
|
+
export async function readFontFile(filePath) {
|
|
21
|
+
if (filePath === '-') {
|
|
22
|
+
return readStdin();
|
|
23
|
+
}
|
|
24
|
+
const resolved = path.resolve(filePath);
|
|
25
|
+
let stat;
|
|
26
|
+
try {
|
|
27
|
+
stat = await fs.stat(resolved);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
31
|
+
throw new Error(`Could not read font file "${filePath}": ${message}`);
|
|
32
|
+
}
|
|
33
|
+
if (!stat.isFile()) {
|
|
34
|
+
throw new Error(`Path "${filePath}" is not a regular file`);
|
|
35
|
+
}
|
|
36
|
+
if (stat.size > MAX_FONT_BYTES) {
|
|
37
|
+
throw new Error(`Font file "${filePath}" exceeds the ${MAX_FONT_BYTES}-byte size cap`);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const buf = await fs.readFile(resolved);
|
|
41
|
+
// Slice into a fresh, non-pooled Uint8Array view so the WASM bridge
|
|
42
|
+
// does not see Node's pooled-slab byte range.
|
|
43
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
47
|
+
throw new Error(`Could not read font file "${filePath}": ${message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Reads all bytes from stdin up to MAX_STDIN_BYTES. */
|
|
51
|
+
async function readStdin() {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
let total = 0;
|
|
54
|
+
for await (const chunk of process.stdin) {
|
|
55
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
56
|
+
total += buf.byteLength;
|
|
57
|
+
if (total > MAX_STDIN_BYTES) {
|
|
58
|
+
throw new Error(`stdin input exceeds the ${MAX_STDIN_BYTES}-byte size cap`);
|
|
59
|
+
}
|
|
60
|
+
chunks.push(buf);
|
|
61
|
+
}
|
|
62
|
+
const combined = Buffer.concat(chunks, total);
|
|
63
|
+
return new Uint8Array(combined.buffer, combined.byteOffset, combined.byteLength).slice();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Writes one or more clamped font output buffers to disk.
|
|
67
|
+
*
|
|
68
|
+
* - Validates each entry's format string against the supported set so a
|
|
69
|
+
* misbehaving engine cannot inject `/`, `..`, or null bytes into the
|
|
70
|
+
* filename via the extension.
|
|
71
|
+
* - Computes the destination path inside `outputDir` and rejects any path
|
|
72
|
+
* that escapes the resolved output directory (defence against
|
|
73
|
+
* `..`/absolute-path injection from configs).
|
|
74
|
+
* - Writes each output atomically: writes to `<dest>.tmp-<pid>-<rand>` and
|
|
75
|
+
* renames into place, so a crash never leaves a half-written font.
|
|
76
|
+
* - Writes are issued in parallel via `Promise.all`.
|
|
77
|
+
* - Refuses to overwrite an existing file unless `options.force` is true.
|
|
78
|
+
*
|
|
79
|
+
* Returns the list of file paths written.
|
|
80
|
+
*/
|
|
81
|
+
export async function writeOutputs(results, outputDir, options = {}) {
|
|
82
|
+
const { force = true } = options;
|
|
83
|
+
const resolvedDir = path.resolve(outputDir);
|
|
84
|
+
// Ensure the output directory exists.
|
|
85
|
+
await fs.mkdir(resolvedDir, { recursive: true });
|
|
86
|
+
const dests = results.map((result) => {
|
|
87
|
+
const safeName = sanitizeFilename(result.name);
|
|
88
|
+
if (!isSupportedFormat(result.format)) {
|
|
89
|
+
throw new Error(`Engine returned unsupported format "${result.format}" for output "${result.name}"`);
|
|
90
|
+
}
|
|
91
|
+
const filename = `${safeName}.${result.format}`;
|
|
92
|
+
const dest = path.join(resolvedDir, filename);
|
|
93
|
+
// Defence in depth: ensure the resolved destination really sits inside
|
|
94
|
+
// resolvedDir even after `path.join` normalisation.
|
|
95
|
+
const rel = path.relative(resolvedDir, dest);
|
|
96
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
97
|
+
throw new Error(`Refusing to write output "${result.name}" outside output directory`);
|
|
98
|
+
}
|
|
99
|
+
return { dest, buffer: result.buffer };
|
|
100
|
+
});
|
|
101
|
+
if (!force) {
|
|
102
|
+
await Promise.all(dests.map(async ({ dest }) => {
|
|
103
|
+
try {
|
|
104
|
+
await fs.access(dest);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return; // file does not exist — good
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`Refusing to overwrite existing file "${dest}" (use --force to overwrite)`);
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
await Promise.all(dests.map(({ dest, buffer }) => writeAtomic(dest, buffer)));
|
|
113
|
+
return dests.map(({ dest }) => dest);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Writes data to a destination path atomically by writing to a sibling
|
|
117
|
+
* tempfile and renaming. Rename is atomic on the same filesystem.
|
|
118
|
+
*/
|
|
119
|
+
async function writeAtomic(dest, data) {
|
|
120
|
+
const tmp = `${dest}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
121
|
+
try {
|
|
122
|
+
await fs.writeFile(tmp, data);
|
|
123
|
+
await fs.rename(tmp, dest);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
// Best-effort cleanup of stray temp file.
|
|
127
|
+
await fs.unlink(tmp).catch(() => { });
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Validates that a font file path has a recognised variable-font extension.
|
|
133
|
+
* Skips the check for `-` (stdin input).
|
|
134
|
+
* Throws if the extension is not recognised.
|
|
135
|
+
*/
|
|
136
|
+
export function assertFontExtension(filePath) {
|
|
137
|
+
if (filePath === '-')
|
|
138
|
+
return;
|
|
139
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
140
|
+
if (!SUPPORTED_INPUT_EXTENSIONS.includes(ext)) {
|
|
141
|
+
throw new Error(`Unrecognised font extension "${ext}". Supported: ${SUPPORTED_INPUT_EXTENSIONS.join(', ')}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Maximum filename length we will accept (most filesystems cap at 255). */
|
|
145
|
+
const MAX_FILENAME_LENGTH = 200;
|
|
146
|
+
/** Windows reserved device names (case-insensitive, with or without extension). */
|
|
147
|
+
const WINDOWS_RESERVED = new Set([
|
|
148
|
+
'con', 'prn', 'aux', 'nul',
|
|
149
|
+
'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
|
|
150
|
+
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
|
|
151
|
+
]);
|
|
152
|
+
/**
|
|
153
|
+
* Sanitizes a string for safe use as a filename stem.
|
|
154
|
+
*
|
|
155
|
+
* Defends against:
|
|
156
|
+
* - path separators (`/`, `\`) being interpreted as directories
|
|
157
|
+
* - null bytes terminating C-string filename APIs
|
|
158
|
+
* - shell-special characters (`:*?"<>|`) on Windows
|
|
159
|
+
* - ASCII control characters (`\0`–`\x1F`, `\x7F`) and Unicode whitespace
|
|
160
|
+
* - embedded `..` segments that would resolve to a parent directory
|
|
161
|
+
* - Windows reserved device names (CON, PRN, AUX, NUL, COM1–9, LPT1–9)
|
|
162
|
+
* - names that are only dots or hyphens (would become empty after trim)
|
|
163
|
+
* - filenames longer than 200 characters
|
|
164
|
+
*
|
|
165
|
+
* Falls back to `"output"` when sanitisation reduces the string to nothing.
|
|
166
|
+
*/
|
|
167
|
+
export function sanitizeFilename(name) {
|
|
168
|
+
// Strip ASCII control characters (including null, newlines, tabs) and DEL.
|
|
169
|
+
let safe = name.replace(/[\x00-\x1F\x7F]/g, '');
|
|
170
|
+
// Replace path separators and common shell-special chars with a hyphen.
|
|
171
|
+
safe = safe.replace(/[/\\:*?"<>|]/g, '-');
|
|
172
|
+
// Collapse `..` runs (which `path.normalize` would otherwise traverse) to
|
|
173
|
+
// a single dot so the result cannot describe a parent directory.
|
|
174
|
+
safe = safe.replace(/\.{2,}/g, '.');
|
|
175
|
+
// Collapse Unicode whitespace runs to a single space, then convert spaces
|
|
176
|
+
// to nothing (most filesystems handle spaces but we prefer not to).
|
|
177
|
+
safe = safe.replace(/\s+/g, ' ');
|
|
178
|
+
// Collapse multiple hyphens.
|
|
179
|
+
safe = safe.replace(/-{2,}/g, '-');
|
|
180
|
+
// Strip leading dots, spaces, and hyphens to avoid hidden files / relative paths.
|
|
181
|
+
safe = safe.replace(/^[.\s-]+/, '');
|
|
182
|
+
// Strip trailing dots, spaces and hyphens.
|
|
183
|
+
safe = safe.replace(/[.\s-]+$/, '');
|
|
184
|
+
// Truncate to a sane length.
|
|
185
|
+
if (safe.length > MAX_FILENAME_LENGTH) {
|
|
186
|
+
safe = safe.slice(0, MAX_FILENAME_LENGTH);
|
|
187
|
+
// Re-strip any trailing hyphens/dots produced by the truncation.
|
|
188
|
+
safe = safe.replace(/[.\s-]+$/, '');
|
|
189
|
+
}
|
|
190
|
+
// Reject Windows reserved device names (case-insensitive).
|
|
191
|
+
if (WINDOWS_RESERVED.has(safe.toLowerCase())) {
|
|
192
|
+
return 'output';
|
|
193
|
+
}
|
|
194
|
+
// Fall back to a safe default if nothing useful is left.
|
|
195
|
+
return safe || 'output';
|
|
196
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats a two-column table for stdout.
|
|
3
|
+
* Each row is [label, value]; label column is padded to `labelWidth` characters.
|
|
4
|
+
*/
|
|
5
|
+
export declare function formatTable(rows: Array<[string, string]>, labelWidth?: number): string;
|
|
6
|
+
/**
|
|
7
|
+
* Returns the arrow glyph (U+2192) when the host terminal supports UTF-8,
|
|
8
|
+
* otherwise `->` for compatibility with cmd.exe and non-UTF locales.
|
|
9
|
+
*/
|
|
10
|
+
export declare function arrowGlyph(): string;
|
|
11
|
+
/**
|
|
12
|
+
* Returns the ellipsis glyph (U+2026) when UTF-8 is supported, else `...`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function ellipsisGlyph(): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Generic UI formatting helpers — colocated here rather than in `utils/font.ts`
|
|
2
|
+
// so font I/O stays decoupled from presentation concerns.
|
|
3
|
+
/**
|
|
4
|
+
* Formats a two-column table for stdout.
|
|
5
|
+
* Each row is [label, value]; label column is padded to `labelWidth` characters.
|
|
6
|
+
*/
|
|
7
|
+
export function formatTable(rows, labelWidth = 18) {
|
|
8
|
+
return rows
|
|
9
|
+
.map(([label, value]) => ` ${label.padEnd(labelWidth)}${value}`)
|
|
10
|
+
.join('\n');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns the arrow glyph (U+2192) when the host terminal supports UTF-8,
|
|
14
|
+
* otherwise `->` for compatibility with cmd.exe and non-UTF locales.
|
|
15
|
+
*/
|
|
16
|
+
export function arrowGlyph() {
|
|
17
|
+
return supportsUtf8() ? '→' : '->';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Returns the ellipsis glyph (U+2026) when UTF-8 is supported, else `...`.
|
|
21
|
+
*/
|
|
22
|
+
export function ellipsisGlyph() {
|
|
23
|
+
return supportsUtf8() ? '…' : '...';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Conservative UTF-8 detection — true when stdout looks Unicode-capable.
|
|
27
|
+
* Falls back to ASCII on Windows cmd.exe and non-UTF locales.
|
|
28
|
+
*/
|
|
29
|
+
function supportsUtf8() {
|
|
30
|
+
if (process.platform === 'win32') {
|
|
31
|
+
// PowerShell/Windows Terminal set this; cmd.exe does not.
|
|
32
|
+
return Boolean(process.env.WT_SESSION || process.env.TERM_PROGRAM);
|
|
33
|
+
}
|
|
34
|
+
const lang = process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '';
|
|
35
|
+
return /UTF-?8/i.test(lang);
|
|
36
|
+
}
|