@wafflebase/cli 0.3.5 → 0.3.7

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 (51) hide show
  1. package/README.md +129 -0
  2. package/dist/bin.js +6 -12
  3. package/dist/client/http-client.d.ts +4 -1
  4. package/dist/client/http-client.js +12 -2
  5. package/dist/commands/api-keys.d.ts +2 -0
  6. package/dist/commands/{api-key.js → api-keys.js} +5 -2
  7. package/dist/commands/cells.d.ts +2 -0
  8. package/dist/commands/{cell.js → cells.js} +5 -2
  9. package/dist/commands/docs.d.ts +3 -0
  10. package/dist/commands/docs.js +306 -0
  11. package/dist/commands/sheets-export.d.ts +2 -0
  12. package/dist/commands/{export.js → sheets-export.js} +2 -2
  13. package/dist/commands/sheets-import.d.ts +2 -0
  14. package/dist/commands/{import.js → sheets-import.js} +2 -2
  15. package/dist/commands/sheets.d.ts +2 -0
  16. package/dist/commands/sheets.js +17 -0
  17. package/dist/commands/tabs.d.ts +2 -0
  18. package/dist/commands/{tab.js → tabs.js} +2 -2
  19. package/dist/docs/content.d.ts +37 -0
  20. package/dist/docs/content.js +90 -0
  21. package/dist/docs/docx-export.d.ts +23 -0
  22. package/dist/docs/docx-export.js +14 -0
  23. package/dist/docs/docx-import.d.ts +36 -0
  24. package/dist/docs/docx-import.js +70 -0
  25. package/dist/docs/dom-polyfill.d.ts +1 -0
  26. package/dist/docs/dom-polyfill.js +29 -0
  27. package/dist/docs/fontkit-measurer.d.ts +41 -0
  28. package/dist/docs/fontkit-measurer.js +64 -0
  29. package/dist/docs/import.d.ts +72 -0
  30. package/dist/docs/import.js +146 -0
  31. package/dist/docs/page-range.d.ts +29 -0
  32. package/dist/docs/page-range.js +74 -0
  33. package/dist/docs/page-slice.d.ts +45 -0
  34. package/dist/docs/page-slice.js +61 -0
  35. package/dist/docs/paginate.d.ts +13 -0
  36. package/dist/docs/paginate.js +19 -0
  37. package/dist/docs/pdf-export.d.ts +38 -0
  38. package/dist/docs/pdf-export.js +91 -0
  39. package/dist/output/binary.d.ts +37 -0
  40. package/dist/output/binary.js +47 -0
  41. package/dist/output/formatter.js +15 -1
  42. package/dist/schema/registry.d.ts +27 -0
  43. package/dist/schema/registry.js +103 -23
  44. package/package.json +7 -2
  45. package/dist/commands/api-key.d.ts +0 -2
  46. package/dist/commands/cell.d.ts +0 -2
  47. package/dist/commands/document.d.ts +0 -2
  48. package/dist/commands/document.js +0 -93
  49. package/dist/commands/export.d.ts +0 -2
  50. package/dist/commands/import.d.ts +0 -2
  51. package/dist/commands/tab.d.ts +0 -2
@@ -0,0 +1,90 @@
1
+ import { writeFileSync, existsSync } from 'node:fs';
2
+ import { serializeJson, serializeMarkdown, serializeText, } from '@wafflebase/docs';
3
+ import { FontkitMeasurer } from './fontkit-measurer.js';
4
+ import { parsePageRange } from './page-range.js';
5
+ import { sliceBlocksByPages } from './page-slice.js';
6
+ import { paginateForCli } from './paginate.js';
7
+ export const VALID_CONTENT_FORMATS = ['json', 'md', 'text'];
8
+ export const LOSSY_NOTICE = 'Lossy conversion: see docs-cli design for the exact mapping';
9
+ export const PAGES_FONT_WARNING = 'Note: --pages uses approximate pagination (no document fonts loaded). ' +
10
+ 'Page boundaries may drift on CJK or font-heavy documents — ' +
11
+ 'use `docs export pdf --pages` for exact subsets.';
12
+ export function parseContentFormat(value) {
13
+ if (!VALID_CONTENT_FORMATS.includes(value)) {
14
+ throw new Error(`Invalid --format "${value}". Use one of: ${VALID_CONTENT_FORMATS.join(', ')}.`);
15
+ }
16
+ return value;
17
+ }
18
+ export const defaultIO = {
19
+ stdout: (text) => {
20
+ process.stdout.write(text);
21
+ if (!text.endsWith('\n'))
22
+ process.stdout.write('\n');
23
+ },
24
+ stderr: (line) => {
25
+ console.error(line);
26
+ },
27
+ writeFile: (path, text, force) => {
28
+ if (existsSync(path) && !force) {
29
+ throw new Error(`Refusing to overwrite "${path}". Pass --force to allow overwrite.`);
30
+ }
31
+ writeFileSync(path, text.endsWith('\n') ? text : text + '\n', 'utf-8');
32
+ },
33
+ };
34
+ /**
35
+ * Pure orchestration for `docs content`: takes an already-fetched
36
+ * Document plus user flags, produces the rendered output, and routes it
37
+ * through the supplied IO surface. Everything between the HTTP fetch
38
+ * and the side-effecting IO lives here so it can be tested without
39
+ * spawning the CLI binary.
40
+ */
41
+ export function runDocsContent(args, io = defaultIO) {
42
+ const { format, pages, includeHeaderFooter = false, inlineImages = false, out, force = false, quiet = false, } = args;
43
+ let working = args.doc;
44
+ let layout;
45
+ if (pages) {
46
+ const measurer = new FontkitMeasurer();
47
+ // `docs content --pages` paginates with a font-less measurer, so
48
+ // every glyph falls back to the 0.5em estimate. That's accurate
49
+ // enough for ASCII-heavy docs but underestimates CJK by ~50% and
50
+ // mis-sizes narrow glyphs (`i`, `:`, `.`) — page boundaries can
51
+ // drift by ±1 page on real documents. `docs export pdf` doesn't
52
+ // suffer from this because PdfPainter does its own measurement at
53
+ // paint time. Pre-loading document fonts here is tracked as a
54
+ // follow-up; until then we surface the limitation on stderr.
55
+ if (!quiet) {
56
+ io.stderr(PAGES_FONT_WARNING);
57
+ }
58
+ const fullLayout = paginateForCli(working, measurer);
59
+ const range = parsePageRange(pages, fullLayout.pages.length);
60
+ if (!quiet) {
61
+ for (const w of range.warnings)
62
+ io.stderr(w);
63
+ }
64
+ const slice = sliceBlocksByPages(working, fullLayout, range, format);
65
+ working = { ...working, blocks: slice.blocks };
66
+ layout = fullLayout;
67
+ }
68
+ if (format === 'md' && !quiet) {
69
+ io.stderr(LOSSY_NOTICE);
70
+ }
71
+ let text;
72
+ switch (format) {
73
+ case 'json':
74
+ text = JSON.stringify(serializeJson(working, layout), null, 2);
75
+ break;
76
+ case 'md':
77
+ text = serializeMarkdown(working, { includeHeaderFooter, inlineImages });
78
+ break;
79
+ case 'text':
80
+ text = serializeText(working, { includeHeaderFooter });
81
+ break;
82
+ }
83
+ if (!out || out === '-') {
84
+ io.stdout(text);
85
+ return;
86
+ }
87
+ io.writeFile(out, text, force);
88
+ if (!quiet)
89
+ io.stderr(`Wrote to ${out}`);
90
+ }
@@ -0,0 +1,23 @@
1
+ import { type Document, type DocxImageFetcher } from '@wafflebase/docs';
2
+ export interface CliDocxExportOptions {
3
+ /** Mirrors `--include-header-footer`; informational only — DOCX always
4
+ * emits the document's `header` / `footer` regions when they exist. */
5
+ includeHeaderFooter?: boolean;
6
+ /** Test seam: provide a stub image fetcher so `exportDocx` doesn't
7
+ * reach out to the network for image inlines. The aliased
8
+ * `DocxImageFetcher` (vs. the bare `ImageFetcher`) survives the
9
+ * Node-bundle resolution path; the bare alias only exists on the
10
+ * browser bundle, so importing it would silently break under any TS
11
+ * config that prefers the `node` exports condition. */
12
+ imageFetcher?: DocxImageFetcher;
13
+ }
14
+ /**
15
+ * Render `doc` to a `.docx` byte buffer via the shared `DocxExporter`.
16
+ *
17
+ * `--pages` is intentionally not handled here — the design (§5.2) says
18
+ * the docx path warns and ignores it; the CLI command emits that
19
+ * warning before calling this function so the helper itself stays a
20
+ * pure renderer. Throws if the document contains image inlines and no
21
+ * `imageFetcher` is supplied (matching `DocxExporter.export` behavior).
22
+ */
23
+ export declare function exportDocx(doc: Document, opts?: CliDocxExportOptions): Promise<Uint8Array>;
@@ -0,0 +1,14 @@
1
+ import { DocxExporter, } from '@wafflebase/docs';
2
+ /**
3
+ * Render `doc` to a `.docx` byte buffer via the shared `DocxExporter`.
4
+ *
5
+ * `--pages` is intentionally not handled here — the design (§5.2) says
6
+ * the docx path warns and ignores it; the CLI command emits that
7
+ * warning before calling this function so the helper itself stays a
8
+ * pure renderer. Throws if the document contains image inlines and no
9
+ * `imageFetcher` is supplied (matching `DocxExporter.export` behavior).
10
+ */
11
+ export async function exportDocx(doc, opts = {}) {
12
+ const blob = await DocxExporter.export(doc, opts.imageFetcher);
13
+ return new Uint8Array(await blob.arrayBuffer());
14
+ }
@@ -0,0 +1,36 @@
1
+ import './dom-polyfill.js';
2
+ import { type Document, type ImageUploader } from '@wafflebase/docs';
3
+ /**
4
+ * Default `ImageUploader` for the CLI: encodes each image inline as a
5
+ * `data:` URL. The Yorkie store treats inline-image `src` values as
6
+ * opaque strings, so a self-contained data URL round-trips cleanly
7
+ * without needing a backend image upload endpoint.
8
+ *
9
+ * The MIME type is derived from the filename's extension (matching what
10
+ * `DocxImporter` infers from `.rels` content types). Unknown
11
+ * extensions fall back to `application/octet-stream` — pdf-lib /
12
+ * browser image rendering will refuse to display those, but at least
13
+ * the document round-trip stays lossless at the JSON layer.
14
+ */
15
+ export declare const inlineBase64Uploader: ImageUploader;
16
+ export interface CliDocxImportOptions {
17
+ /** Override the default base64 uploader — primarily for tests. */
18
+ imageUploader?: ImageUploader;
19
+ }
20
+ /**
21
+ * Parse a .docx byte buffer into a `Document` using the editor's
22
+ * `DocxImporter`. The polyfill side-effect import at the top of this
23
+ * file installs `DOMParser` from `@xmldom/xmldom` so the importer's
24
+ * browser-targeted XML parsing works in Node.
25
+ *
26
+ * Throws a wrapped `INVALID_DOCX` error when the underlying importer
27
+ * cannot make sense of the buffer (missing `word/document.xml`,
28
+ * malformed XML, unsupported zip layout). The CLI command translates
29
+ * the wrapper into the documented `{ error.code: 'INVALID_DOCX' }`
30
+ * exit body without leaking implementation details.
31
+ */
32
+ export declare function importDocx(buf: Uint8Array, opts?: CliDocxImportOptions): Promise<Document>;
33
+ export declare class InvalidDocxError extends Error {
34
+ readonly code = "INVALID_DOCX";
35
+ constructor(message: string);
36
+ }
@@ -0,0 +1,70 @@
1
+ import './dom-polyfill.js';
2
+ import { DocxImporter, } from '@wafflebase/docs';
3
+ /**
4
+ * Default `ImageUploader` for the CLI: encodes each image inline as a
5
+ * `data:` URL. The Yorkie store treats inline-image `src` values as
6
+ * opaque strings, so a self-contained data URL round-trips cleanly
7
+ * without needing a backend image upload endpoint.
8
+ *
9
+ * The MIME type is derived from the filename's extension (matching what
10
+ * `DocxImporter` infers from `.rels` content types). Unknown
11
+ * extensions fall back to `application/octet-stream` — pdf-lib /
12
+ * browser image rendering will refuse to display those, but at least
13
+ * the document round-trip stays lossless at the JSON layer.
14
+ */
15
+ export const inlineBase64Uploader = async (blob, filename) => {
16
+ const arrayBuf = await blob.arrayBuffer();
17
+ const base64 = Buffer.from(arrayBuf).toString('base64');
18
+ const mime = blob.type || mimeFromFilename(filename) || 'application/octet-stream';
19
+ return `data:${mime};base64,${base64}`;
20
+ };
21
+ function mimeFromFilename(filename) {
22
+ const dot = filename.lastIndexOf('.');
23
+ if (dot < 0)
24
+ return undefined;
25
+ const ext = filename.slice(dot + 1).toLowerCase();
26
+ switch (ext) {
27
+ case 'png':
28
+ return 'image/png';
29
+ case 'jpg':
30
+ case 'jpeg':
31
+ return 'image/jpeg';
32
+ case 'gif':
33
+ return 'image/gif';
34
+ case 'webp':
35
+ return 'image/webp';
36
+ case 'bmp':
37
+ return 'image/bmp';
38
+ default:
39
+ return undefined;
40
+ }
41
+ }
42
+ /**
43
+ * Parse a .docx byte buffer into a `Document` using the editor's
44
+ * `DocxImporter`. The polyfill side-effect import at the top of this
45
+ * file installs `DOMParser` from `@xmldom/xmldom` so the importer's
46
+ * browser-targeted XML parsing works in Node.
47
+ *
48
+ * Throws a wrapped `INVALID_DOCX` error when the underlying importer
49
+ * cannot make sense of the buffer (missing `word/document.xml`,
50
+ * malformed XML, unsupported zip layout). The CLI command translates
51
+ * the wrapper into the documented `{ error.code: 'INVALID_DOCX' }`
52
+ * exit body without leaking implementation details.
53
+ */
54
+ export async function importDocx(buf, opts = {}) {
55
+ const arrayBuf = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
56
+ try {
57
+ return await DocxImporter.import(arrayBuf, opts.imageUploader ?? inlineBase64Uploader);
58
+ }
59
+ catch (e) {
60
+ const message = e instanceof Error ? e.message : String(e);
61
+ throw new InvalidDocxError(message);
62
+ }
63
+ }
64
+ export class InvalidDocxError extends Error {
65
+ code = 'INVALID_DOCX';
66
+ constructor(message) {
67
+ super(message);
68
+ this.name = 'InvalidDocxError';
69
+ }
70
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { DOMParser as XmldomParser } from '@xmldom/xmldom';
2
+ /**
3
+ * Make `DOMParser` available globally so `@wafflebase/docs`'s
4
+ * `DocxImporter` can call `new DOMParser()` from a Node CLI process.
5
+ *
6
+ * `DocxImporter` was originally written for the browser and parses
7
+ * `.docx` XML through the WHATWG DOMParser. Node has no built-in DOM,
8
+ * so we substitute `@xmldom/xmldom`'s implementation — it's an XML-only
9
+ * parser (no HTML quirks, no full DOM tree mutation) but covers the
10
+ * `getElementsByTagNameNS` / `textContent` / `getAttribute` surface the
11
+ * importer actually touches.
12
+ *
13
+ * The shim is idempotent: if a real `DOMParser` is already present
14
+ * (jsdom-environment tests, future Node releases that ship one) we
15
+ * leave it alone so production code continues to use the more featureful
16
+ * implementation.
17
+ *
18
+ * Side-effect import — bring this file in *before* any code path that
19
+ * calls `DocxImporter.import`.
20
+ */
21
+ const g = globalThis;
22
+ if (typeof g.DOMParser === 'undefined') {
23
+ // Assign through an `unknown` cast because `@xmldom/xmldom`'s
24
+ // `DOMParser` shape is a runtime superset of WHATWG's but uses its
25
+ // own type declarations. `DocxImporter` only consumes the XML subset
26
+ // both implementations share, so the runtime contract holds even
27
+ // though the static types don't unify.
28
+ g.DOMParser = XmldomParser;
29
+ }
@@ -0,0 +1,41 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import type { TextMeasurer, ResolvedFont } from '@wafflebase/docs';
3
+ export interface FontkitMeasurerOptions {
4
+ /**
5
+ * Width per character in em-units used when no font is registered for
6
+ * the requested variant. Multiplied by `font.size` to produce a px value.
7
+ * Defaults to `0.5` — a coarse proportional-font approximation that
8
+ * keeps pagination from collapsing to zero when a font is missing.
9
+ *
10
+ * Callers that pre-load every font referenced by a document never hit
11
+ * this path; the fallback exists so a single missing variant does not
12
+ * crash the CLI mid-pagination.
13
+ */
14
+ fallbackEmWidth?: number;
15
+ }
16
+ /**
17
+ * `TextMeasurer` backed by `fontkit` for use in Node CLI contexts where
18
+ * no Canvas 2D API is available. Width is computed as
19
+ * `glyphRun.advanceWidth ÷ unitsPerEm × size` for each registered font.
20
+ *
21
+ * Measurement is synchronous; callers must `register()` every font
22
+ * variant they need *before* invoking `paginateLayout` / `measureWidth`.
23
+ * Loading is async (file/network IO) but the layout pipeline is not, so
24
+ * we split the two phases.
25
+ */
26
+ export declare class FontkitMeasurer implements TextMeasurer {
27
+ private readonly fonts;
28
+ private readonly fallbackEmWidth;
29
+ constructor(opts?: FontkitMeasurerOptions);
30
+ /**
31
+ * Register a font buffer for a specific variant. Subsequent
32
+ * `measureWidth` calls whose `(family, weight, style)` matches will use
33
+ * this font's metrics. A `FontCollection` (.ttc) input is rejected
34
+ * here — the caller must pick the desired face out of the collection
35
+ * first to keep the cache key unambiguous.
36
+ */
37
+ register(family: string, weight: ResolvedFont['weight'], style: ResolvedFont['style'], buffer: Uint8Array | ArrayBuffer | Buffer): void;
38
+ /** True when the variant has a registered font (no fallback path). */
39
+ has(family: string, weight: ResolvedFont['weight'], style: ResolvedFont['style']): boolean;
40
+ measureWidth(text: string, font: ResolvedFont): number;
41
+ }
@@ -0,0 +1,64 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import * as fontkit from 'fontkit';
3
+ /**
4
+ * Variant key for the in-memory font cache. Lowercased family name plus
5
+ * weight/style ensures `'Helvetica'` and `'helvetica'` resolve to the same
6
+ * registered buffer — Canvas measurement is case-insensitive on family
7
+ * lookups, so we mirror that here so the CLI matches browser pagination.
8
+ */
9
+ function variantKey(family, weight, style) {
10
+ return `${family.toLowerCase()}|${weight}|${style}`;
11
+ }
12
+ /**
13
+ * `TextMeasurer` backed by `fontkit` for use in Node CLI contexts where
14
+ * no Canvas 2D API is available. Width is computed as
15
+ * `glyphRun.advanceWidth ÷ unitsPerEm × size` for each registered font.
16
+ *
17
+ * Measurement is synchronous; callers must `register()` every font
18
+ * variant they need *before* invoking `paginateLayout` / `measureWidth`.
19
+ * Loading is async (file/network IO) but the layout pipeline is not, so
20
+ * we split the two phases.
21
+ */
22
+ export class FontkitMeasurer {
23
+ fonts = new Map();
24
+ fallbackEmWidth;
25
+ constructor(opts = {}) {
26
+ this.fallbackEmWidth = opts.fallbackEmWidth ?? 0.5;
27
+ }
28
+ /**
29
+ * Register a font buffer for a specific variant. Subsequent
30
+ * `measureWidth` calls whose `(family, weight, style)` matches will use
31
+ * this font's metrics. A `FontCollection` (.ttc) input is rejected
32
+ * here — the caller must pick the desired face out of the collection
33
+ * first to keep the cache key unambiguous.
34
+ */
35
+ register(family, weight, style, buffer) {
36
+ const buf = toBuffer(buffer);
37
+ const parsed = fontkit.create(buf);
38
+ if ('fonts' in parsed) {
39
+ throw new Error(`FontkitMeasurer.register("${family}"): TrueType collections (.ttc) ` +
40
+ `must be split into individual faces before registration.`);
41
+ }
42
+ this.fonts.set(variantKey(family, weight, style), parsed);
43
+ }
44
+ /** True when the variant has a registered font (no fallback path). */
45
+ has(family, weight, style) {
46
+ return this.fonts.has(variantKey(family, weight, style));
47
+ }
48
+ measureWidth(text, font) {
49
+ if (text.length === 0)
50
+ return 0;
51
+ const f = this.fonts.get(variantKey(font.family, font.weight, font.style));
52
+ if (!f)
53
+ return text.length * this.fallbackEmWidth * font.size;
54
+ const run = f.layout(text);
55
+ return (run.advanceWidth / f.unitsPerEm) * font.size;
56
+ }
57
+ }
58
+ function toBuffer(input) {
59
+ if (Buffer.isBuffer(input))
60
+ return input;
61
+ if (input instanceof ArrayBuffer)
62
+ return Buffer.from(new Uint8Array(input));
63
+ return Buffer.from(input);
64
+ }
@@ -0,0 +1,72 @@
1
+ import type { Document, ImageUploader } from '@wafflebase/docs';
2
+ /**
3
+ * Minimal HTTP surface `runDocsImport` needs from the CLI's
4
+ * `HttpClient`. Spelled out so tests can pass a stub without depending
5
+ * on the full client.
6
+ */
7
+ export interface ImportClient {
8
+ createDocument: (title: string, type?: 'doc' | 'sheet') => Promise<{
9
+ ok: boolean;
10
+ status: number;
11
+ data: unknown;
12
+ }>;
13
+ putDocContent: (docId: string, doc: Document) => Promise<{
14
+ ok: boolean;
15
+ status: number;
16
+ data: unknown;
17
+ }>;
18
+ }
19
+ export interface ImportIO {
20
+ /** Final structured payload — written to stdout as JSON. */
21
+ stdout: (line: string) => void;
22
+ stderr: (line: string) => void;
23
+ /**
24
+ * Read the docx bytes for `path`. `'-'` reads from stdin.
25
+ *
26
+ * Split out so tests can inject in-memory buffers without juggling
27
+ * tmp files; the default reads `process.stdin` (or the file
28
+ * synchronously) at the action site.
29
+ */
30
+ readBytes: (path: string) => Promise<Uint8Array>;
31
+ /**
32
+ * Prompt the user for a yes/no answer when running interactively.
33
+ * Returns `true` for affirmative responses (case-insensitive `y` /
34
+ * `yes`); anything else (including end-of-stream) → `false`.
35
+ */
36
+ confirm: (prompt: string) => Promise<boolean>;
37
+ /** Whether the current process is interactive (a TTY on stdin). * Tests inject `false` to exercise the non-interactive branches. */
38
+ isTTY: boolean;
39
+ }
40
+ export declare const defaultImportIO: ImportIO;
41
+ export interface RunImportArgs {
42
+ /** Source path. `'-'` reads from stdin. */
43
+ file: string;
44
+ /** Override the doc title. Falls back to the file basename. */
45
+ title?: string;
46
+ /** Existing document ID to overwrite. Pairs with `--yes`. */
47
+ replace?: string;
48
+ /** Skip the interactive `--replace` confirmation. */
49
+ yes?: boolean;
50
+ /** Override the default base64 image uploader. Tests pass a no-op. */
51
+ imageUploader?: ImageUploader;
52
+ /** Suppress informational stderr output (errors still reported). */
53
+ quiet?: boolean;
54
+ /** Print the request that *would* fire instead of issuing it. */
55
+ dryRun?: boolean;
56
+ }
57
+ export interface RunImportResult {
58
+ /** Process exit code. The action sets `process.exitCode` accordingly. */
59
+ exitCode: number;
60
+ }
61
+ /**
62
+ * Pure orchestration for `docs import`. Reads the source `.docx`,
63
+ * parses it into a `Document`, then either creates a new document
64
+ * (default) or overwrites an existing one (`--replace`).
65
+ *
66
+ * Why split out from the command action: the action's layered handling
67
+ * of stdin / TTY / interactive prompt is the easiest path to test
68
+ * regressions in. Mocking `process.stdin` at the harness level is
69
+ * fragile; passing in an `ImportIO` lets us drive every branch
70
+ * deterministically.
71
+ */
72
+ export declare function runDocsImport(args: RunImportArgs, client: ImportClient, io?: ImportIO): Promise<RunImportResult>;
@@ -0,0 +1,146 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename, extname } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { importDocx, InvalidDocxError } from './docx-import.js';
5
+ export const defaultImportIO = {
6
+ stdout: (line) => {
7
+ process.stdout.write(line);
8
+ if (!line.endsWith('\n'))
9
+ process.stdout.write('\n');
10
+ },
11
+ stderr: (line) => {
12
+ console.error(line);
13
+ },
14
+ readBytes: async (path) => {
15
+ if (path === '-') {
16
+ return new Promise((resolve, reject) => {
17
+ const chunks = [];
18
+ process.stdin.on('data', (c) => chunks.push(c));
19
+ process.stdin.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))));
20
+ process.stdin.on('error', reject);
21
+ });
22
+ }
23
+ return new Uint8Array(readFileSync(path));
24
+ },
25
+ confirm: (prompt) => {
26
+ return new Promise((resolve) => {
27
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
28
+ rl.question(prompt, (answer) => {
29
+ rl.close();
30
+ const a = answer.trim().toLowerCase();
31
+ resolve(a === 'y' || a === 'yes');
32
+ });
33
+ });
34
+ },
35
+ isTTY: process.stdin.isTTY ?? false,
36
+ };
37
+ /**
38
+ * Pure orchestration for `docs import`. Reads the source `.docx`,
39
+ * parses it into a `Document`, then either creates a new document
40
+ * (default) or overwrites an existing one (`--replace`).
41
+ *
42
+ * Why split out from the command action: the action's layered handling
43
+ * of stdin / TTY / interactive prompt is the easiest path to test
44
+ * regressions in. Mocking `process.stdin` at the harness level is
45
+ * fragile; passing in an `ImportIO` lets us drive every branch
46
+ * deterministically.
47
+ */
48
+ export async function runDocsImport(args, client, io = defaultImportIO) {
49
+ const { file, replace, yes = false, imageUploader, quiet = false, dryRun = false } = args;
50
+ const inferredTitle = args.title ?? defaultTitleFor(file);
51
+ if (replace) {
52
+ // `--replace` is destructive: confirm interactively unless `--yes`.
53
+ if (!yes) {
54
+ if (!io.isTTY) {
55
+ io.stderr(JSON.stringify({
56
+ error: {
57
+ code: 'CONFIRMATION_REQ',
58
+ message: `Pass --yes to confirm replacing document "${replace}".`,
59
+ },
60
+ }, null, 2));
61
+ return { exitCode: 1 };
62
+ }
63
+ const ok = await io.confirm(`This will replace content of ${replace}. Continue? [y/N] `);
64
+ if (!ok) {
65
+ if (!quiet)
66
+ io.stderr('Aborted.');
67
+ return { exitCode: 0 };
68
+ }
69
+ }
70
+ const buf = await io.readBytes(file);
71
+ const parsed = await safeImportDocx(buf, imageUploader, io);
72
+ if (parsed === null)
73
+ return { exitCode: 1 };
74
+ const doc = parsed;
75
+ if (dryRun) {
76
+ io.stdout(JSON.stringify({ method: 'PUT', path: `/documents/${replace}/content`, body: doc }, null, 2));
77
+ return { exitCode: 0 };
78
+ }
79
+ const res = await client.putDocContent(replace, doc);
80
+ if (!res.ok) {
81
+ io.stderr(JSON.stringify(res.data ?? { error: { code: 'HTTP_ERROR' } }, null, 2));
82
+ return { exitCode: 1 };
83
+ }
84
+ io.stdout(JSON.stringify({ id: replace, replaced: true }, null, 2));
85
+ return { exitCode: 0 };
86
+ }
87
+ // Default flow: POST + PUT.
88
+ const buf = await io.readBytes(file);
89
+ const parsedNew = await safeImportDocx(buf, imageUploader, io);
90
+ if (parsedNew === null)
91
+ return { exitCode: 1 };
92
+ const doc = parsedNew;
93
+ if (dryRun) {
94
+ io.stdout(JSON.stringify({
95
+ method: 'POST',
96
+ path: '/documents',
97
+ body: { title: inferredTitle, type: 'doc' },
98
+ followUp: { method: 'PUT', path: '/documents/<new-id>/content' },
99
+ }, null, 2));
100
+ return { exitCode: 0 };
101
+ }
102
+ const created = await client.createDocument(inferredTitle, 'doc');
103
+ if (!created.ok) {
104
+ io.stderr(JSON.stringify(created.data ?? { error: { code: 'HTTP_ERROR' } }, null, 2));
105
+ return { exitCode: 1 };
106
+ }
107
+ const newId = created.data?.id;
108
+ if (!newId) {
109
+ io.stderr(JSON.stringify({ error: { code: 'INVALID_RESPONSE', message: 'Server did not return an id' } }, null, 2));
110
+ return { exitCode: 1 };
111
+ }
112
+ const put = await client.putDocContent(newId, doc);
113
+ if (!put.ok) {
114
+ io.stderr(JSON.stringify(put.data ?? { error: { code: 'HTTP_ERROR' } }, null, 2));
115
+ return { exitCode: 1 };
116
+ }
117
+ io.stdout(JSON.stringify({ id: newId, title: inferredTitle }, null, 2));
118
+ return { exitCode: 0 };
119
+ }
120
+ /** Strip the directory and extension to produce a default doc title. */
121
+ function defaultTitleFor(file) {
122
+ if (file === '-')
123
+ return 'Untitled';
124
+ return basename(file, extname(file)) || 'Untitled';
125
+ }
126
+ /**
127
+ * Run `importDocx` and turn `InvalidDocxError` into a structured
128
+ * stderr body + `null` return so callers can short-circuit with
129
+ * `exitCode: 1` while keeping the rest of the error envelope
130
+ * (`HTTP_ERROR`, `INVALID_RESPONSE`, etc.) consistent. The action's
131
+ * `outputError` would technically also surface the error code today
132
+ * (post-B1), but catching here keeps the contract local to this file
133
+ * and matches every other failure path in `runDocsImport`.
134
+ */
135
+ async function safeImportDocx(buf, imageUploader, io) {
136
+ try {
137
+ return await importDocx(buf, { imageUploader });
138
+ }
139
+ catch (e) {
140
+ if (e instanceof InvalidDocxError) {
141
+ io.stderr(JSON.stringify({ error: { code: e.code, message: e.message } }, null, 2));
142
+ return null;
143
+ }
144
+ throw e;
145
+ }
146
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Resolved page selection produced by {@link parsePageRange}.
3
+ *
4
+ * `pages` is the deduplicated set of 1-based page numbers requested by
5
+ * the user, after clamping to the document's actual page count.
6
+ *
7
+ * `warnings` collects clamp/duplicate notices the caller should emit to
8
+ * stderr (parsing succeeded; the user just asked for pages that don't
9
+ * exist). Hard errors throw instead — see {@link parsePageRange}.
10
+ */
11
+ export interface PageRange {
12
+ pages: ReadonlySet<number>;
13
+ warnings: ReadonlyArray<string>;
14
+ }
15
+ /**
16
+ * Parse a `--pages` flag value (e.g., `"1-3,5,7-9"`) against a known
17
+ * page count.
18
+ *
19
+ * Throws `Error` on malformed input — empty strings, non-numeric tokens,
20
+ * a `0` page (1-based), or a reversed range (`"3-1"`). Out-of-range
21
+ * upper bounds are *not* errors: they get clamped to `totalPages` with a
22
+ * stderr-bound warning string in the result. Tokens whose entire range
23
+ * sits past `totalPages` collapse to no pages with a warning, never an
24
+ * error — this lets `--pages 1-99` work on a 3-page doc.
25
+ *
26
+ * `totalPages` of 0 is a valid input (empty doc) and yields an empty set
27
+ * with a single "document has no pages" warning, regardless of input.
28
+ */
29
+ export declare function parsePageRange(input: string, totalPages: number): PageRange;