@gpdoc/cli 1.0.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.
Files changed (3) hide show
  1. package/README.md +21 -0
  2. package/bin/gpdoc.js +189 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # GPDoc CLI
2
+
3
+ The GPDoc CLI creates, inspects, validates, and converts local document files. It keeps rich editing in GPEditor or the editor configured through `$VISUAL` or `$EDITOR`.
4
+
5
+ From this repository, run commands through `npm run cli --`:
6
+
7
+ ```sh
8
+ npm run cli -- new docs/release-plan.gpdoc.md --title "Release plan"
9
+ npm run cli -- convert notes.html --to gpdoc --output docs/notes.gpdoc.md
10
+ npm run cli -- convert https://example.com/guide --to markdown --output guide.md
11
+ npm run cli -- validate docs/notes.gpdoc.md --json
12
+ npm run cli -- edit docs/notes.gpdoc.md
13
+ ```
14
+
15
+ Supported input formats are Markdown, managed GPDoc Markdown, HTML, plain text, GPDoc JSON documents, DOCX, and PPTX text. Supported outputs are `gpdoc`, `markdown`, `html`, `text`, `json`, `docx`, and `pdf`. DOCX/PPTX conversion preserves text and headings, not full presentation layout; PDF output is text-only. PDF input, RTF input, and PPTX output return an explicit unsupported-format error.
16
+
17
+ Conversions write to standard output or an explicit output path. Existing files are never overwritten unless `--in-place` is supplied for the source file.
18
+
19
+ ## Installation
20
+
21
+ After the public npm release, install with `npm install --global @gpdoc/cli`, or run one command without installation through `npx --yes @gpdoc/cli`. The initial distribution target is npm because the CLI requires Node.js 20 or later on every supported platform. A Homebrew formula and signed macOS, Windows, and Linux downloads should follow once release artifacts and signing are in place.
package/bin/gpdoc.js ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ // @spec CLI-001, CLI-002, CLI-003, CLI-004, CLI-005, CLI-006, CLI-007, CLI-008, CLI-009, CLI-014, CLI-015
3
+ import { access, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
4
+ import { spawn } from 'node:child_process';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+ const {
8
+ convertDocument,
9
+ createManagedDocument,
10
+ detectFormat,
11
+ inspectDocument,
12
+ readDocument,
13
+ readUrlDocument,
14
+ serializeManagedMarkdown,
15
+ validateDocument,
16
+ } = await import('@gpdoc/filekit').catch(() => import('../../gpdoc-filekit/src/index.js'));
17
+
18
+ class CliError extends Error {
19
+ constructor(code, message) {
20
+ super(message);
21
+ this.code = code;
22
+ }
23
+ }
24
+
25
+ function usage() {
26
+ return `Usage:\n gpdoc new OUTPUT [--title TITLE] [--json]\n gpdoc inspect INPUT [--json]\n gpdoc validate INPUT [--json]\n gpdoc convert INPUT --to gpdoc|markdown|html|text|json|docx|pdf [--output PATH | --in-place] [--json]\n gpdoc edit INPUT [--json]\n`;
27
+ }
28
+
29
+ function parseArguments(argv) {
30
+ const [command, ...rest] = argv;
31
+ if (!command || ['--help', '-h', 'help'].includes(command)) return { command: 'help', positionals: [], options: {} };
32
+ const positionals = [];
33
+ const options = {};
34
+ for (let index = 0; index < rest.length; index += 1) {
35
+ const value = rest[index];
36
+ if (!value.startsWith('--')) {
37
+ positionals.push(value);
38
+ continue;
39
+ }
40
+ const key = value.slice(2);
41
+ if (['json', 'in-place'].includes(key)) {
42
+ options[key] = true;
43
+ continue;
44
+ }
45
+ if (!['title', 'to', 'output'].includes(key) || !rest[index + 1] || rest[index + 1].startsWith('--')) {
46
+ throw new CliError('USAGE', `Unknown or incomplete option: ${value}.`);
47
+ }
48
+ options[key] = rest[index + 1];
49
+ index += 1;
50
+ }
51
+ return { command, positionals, options };
52
+ }
53
+
54
+ function requireInput(parsed) {
55
+ const input = parsed.positionals[0];
56
+ if (!input) throw new CliError('USAGE', 'An input path is required.');
57
+ return input;
58
+ }
59
+
60
+ async function exists(target) {
61
+ try {
62
+ await stat(target);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ async function readStdin() {
70
+ let body = '';
71
+ for await (const chunk of process.stdin) body += chunk;
72
+ return body;
73
+ }
74
+
75
+ async function loadDocument(input) {
76
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) {
77
+ return { document: await readUrlDocument(input), source: null };
78
+ }
79
+ if (input === '-') {
80
+ return { document: readDocument(await readStdin(), 'stdin.md'), source: null };
81
+ }
82
+ const format = detectFormat(input);
83
+ const source = ['docx', 'pptx'].includes(format) ? await readFile(input) : await readFile(input, 'utf8');
84
+ return { document: readDocument(source, input), source: input };
85
+ }
86
+
87
+ async function atomicWrite(target, content) {
88
+ const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
89
+ try {
90
+ await writeFile(temporary, content);
91
+ await rename(temporary, target);
92
+ } catch (error) {
93
+ await unlink(temporary).catch(() => {});
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ function printResult(result, json) {
99
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
100
+ else if (typeof result === 'string') process.stdout.write(result.endsWith('\n') ? result : `${result}\n`);
101
+ else process.stdout.write(`${Object.entries(result).map(([key, value]) => `${key}: ${value}`).join('\n')}\n`);
102
+ }
103
+
104
+ function printError(error, json) {
105
+ const result = { error: error.code || 'ERROR', message: error.message || String(error) };
106
+ if (json) process.stderr.write(`${JSON.stringify(result)}\n`);
107
+ else process.stderr.write(`${result.error}: ${result.message}\n`);
108
+ }
109
+
110
+ function launchEditor(editor, target) {
111
+ return new Promise((resolve, reject) => {
112
+ const child = spawn(`${editor} ${JSON.stringify(target)}`, { shell: true, stdio: 'inherit' });
113
+ child.on('error', reject);
114
+ child.on('exit', (code) => code === 0 ? resolve() : reject(new CliError('EDITOR_FAILED', `Editor exited with status ${code}.`)));
115
+ });
116
+ }
117
+
118
+ export async function run(argv) {
119
+ const parsed = parseArguments(argv);
120
+ if (parsed.command === 'help') {
121
+ process.stdout.write(usage());
122
+ return;
123
+ }
124
+ if (!['new', 'inspect', 'validate', 'convert', 'edit'].includes(parsed.command)) {
125
+ throw new CliError('USAGE', `Unknown command: ${parsed.command}.`);
126
+ }
127
+
128
+ if (parsed.command === 'new') {
129
+ const target = requireInput(parsed);
130
+ if (await exists(target)) throw new CliError('OUTPUT_EXISTS', `Refusing to overwrite existing output: ${target}. Use --in-place only for the source file.`);
131
+ const title = parsed.options.title || path.basename(target).replace(/(?:\.gpdoc)?\.md$/i, '') || 'Untitled';
132
+ await atomicWrite(target, serializeManagedMarkdown(createManagedDocument(title), ''));
133
+ printResult(parsed.options.json ? { output: target, format: 'gpdoc' } : `Created ${target}`, parsed.options.json);
134
+ return;
135
+ }
136
+
137
+ const input = requireInput(parsed);
138
+ if (parsed.command === 'edit') {
139
+ if (input === '-') throw new CliError('USAGE', 'gpdoc edit requires a file path.');
140
+ const editor = process.env.VISUAL || process.env.EDITOR;
141
+ if (!editor) throw new CliError('EDITOR_UNAVAILABLE', 'Set $VISUAL or $EDITOR before using gpdoc edit.');
142
+ await access(input);
143
+ await launchEditor(editor, input);
144
+ const { document } = await loadDocument(input);
145
+ validateDocument(document);
146
+ printResult({ valid: true, path: input, format: document.format }, parsed.options.json);
147
+ return;
148
+ }
149
+
150
+ const { document, source } = await loadDocument(input);
151
+ if (parsed.command === 'inspect') {
152
+ printResult(inspectDocument(document, input), parsed.options.json);
153
+ return;
154
+ }
155
+ if (parsed.command === 'validate') {
156
+ validateDocument(document);
157
+ printResult({ valid: true, path: input, format: document.format }, parsed.options.json);
158
+ return;
159
+ }
160
+
161
+ if (!parsed.options.to) throw new CliError('USAGE', 'gpdoc convert requires --to.');
162
+ if (parsed.options['in-place'] && parsed.options.output) throw new CliError('USAGE', 'Use either --output or --in-place, not both.');
163
+ const content = convertDocument(document, parsed.options.to);
164
+ const output = parsed.options['in-place'] ? source : parsed.options.output;
165
+ if (parsed.options['in-place'] && !source) throw new CliError('USAGE', '--in-place cannot be used with standard input.');
166
+ if (output) {
167
+ if (!parsed.options['in-place'] && await exists(output)) {
168
+ throw new CliError('OUTPUT_EXISTS', `Refusing to overwrite existing output: ${output}. Use --in-place only for the source file.`);
169
+ }
170
+ await atomicWrite(output, content);
171
+ } else if (parsed.options.json) {
172
+ throw new CliError('USAGE', '--json conversion output requires --output.');
173
+ } else {
174
+ process.stdout.write(content);
175
+ }
176
+ const warnings = [...document.warnings];
177
+ if (String(parsed.options.to).toLowerCase() === 'pdf') warnings.push('PDF export preserves text, not browser-rendered layout.');
178
+ const result = { format: parsed.options.to, output: output || null, warnings };
179
+ if (parsed.options.json) printResult(result, true);
180
+ else if (warnings.length) process.stderr.write(`${warnings.join('\n')}\n`);
181
+ }
182
+
183
+ try {
184
+ await run(process.argv.slice(2));
185
+ } catch (error) {
186
+ const json = process.argv.includes('--json');
187
+ printError(error, json);
188
+ process.exitCode = 1;
189
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@gpdoc/cli",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "GPDoc command-line file conversion and validation tools",
6
+ "repository": "https://github.com/repetere/gpdoc.git",
7
+ "files": [
8
+ "bin",
9
+ "README.md"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "bin": {
15
+ "gpdoc": "./bin/gpdoc.js"
16
+ },
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test test/**/*.test.js"
22
+ },
23
+ "dependencies": {
24
+ "@gpdoc/filekit": "^1.0.0"
25
+ }
26
+ }