@wafflebase/cli 0.4.6 → 0.4.8

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.
@@ -30,6 +30,7 @@ export declare class HttpClient {
30
30
  deleteDocument(id: string): Promise<ApiResponse<unknown>>;
31
31
  getDocContent(docId: string): Promise<ApiResponse<Document>>;
32
32
  putDocContent(docId: string, doc: Document): Promise<ApiResponse<Document>>;
33
+ getSlidesContent(docId: string): Promise<ApiResponse<SlidesDocument>>;
33
34
  putSlidesContent(docId: string, deck: SlidesDocument): Promise<ApiResponse<SlidesDocument>>;
34
35
  listTabs(docId: string): Promise<ApiResponse<unknown[]>>;
35
36
  getCells(docId: string, tabId: string, range?: string): Promise<ApiResponse<unknown>>;
@@ -119,6 +119,9 @@ export class HttpClient {
119
119
  // Slides content — same endpoint as docs; the backend dispatches on
120
120
  // the persisted document type, picking the docs writer for `'doc'`
121
121
  // and the slides writer for `'slides'`.
122
+ getSlidesContent(docId) {
123
+ return this.request('GET', `/documents/${docId}/content`);
124
+ }
122
125
  putSlidesContent(docId, deck) {
123
126
  return this.request('PUT', `/documents/${docId}/content`, deck);
124
127
  }
@@ -1,12 +1,201 @@
1
- import { getGlobalOpts, getClient } from './root.js';
2
- import { outputError } from '../output/formatter.js';
1
+ import { extname } from 'node:path';
2
+ import { getGlobalOpts, getClient, getConfig } from './root.js';
3
+ import { output, outputError } from '../output/formatter.js';
4
+ import { printDryRun } from '../client/dry-run.js';
3
5
  import { runSlidesImport } from '../slides/import.js';
6
+ import { parseSlidesContentFormat, runSlidesContent, } from '../slides/content.js';
7
+ import { writeBinary } from '../output/binary.js';
8
+ import { createImageFetcher } from '../docs/image-fetcher.js';
9
+ import { exportPptxCli } from '../slides/pptx-export.js';
4
10
  export function registerSlidesCommand(program) {
5
11
  const slides = program
6
12
  .command('slides')
7
13
  .alias('slide')
8
14
  .alias('deck')
9
15
  .description('Manage slide decks');
16
+ slides
17
+ .command('list')
18
+ .description('List slide decks in workspace')
19
+ .action(async function () {
20
+ const opts = getGlobalOpts(this);
21
+ try {
22
+ const res = await getClient(opts).listDocuments();
23
+ if (!res.ok)
24
+ throw new Error(`HTTP ${res.status}`);
25
+ let data = res.data;
26
+ if (Array.isArray(data)) {
27
+ data = data.filter((d) => d.type === 'slides');
28
+ }
29
+ output(data, opts.format, opts.quiet);
30
+ }
31
+ catch (e) {
32
+ outputError(e, opts.quiet);
33
+ }
34
+ });
35
+ slides
36
+ .command('create <title>')
37
+ .description('Create a new slide deck')
38
+ .action(async function (title) {
39
+ const opts = getGlobalOpts(this);
40
+ try {
41
+ if (opts.dryRun) {
42
+ printDryRun(getConfig(opts), 'POST', '/documents', {
43
+ title,
44
+ type: 'slides',
45
+ });
46
+ return;
47
+ }
48
+ const res = await getClient(opts).createDocument(title, 'slides');
49
+ if (!res.ok)
50
+ throw new Error(`HTTP ${res.status}`);
51
+ output(res.data, opts.format, opts.quiet);
52
+ }
53
+ catch (e) {
54
+ outputError(e, opts.quiet);
55
+ }
56
+ });
57
+ slides
58
+ .command('get <doc-id>')
59
+ .description('Show slide deck metadata')
60
+ .action(async function (docId) {
61
+ const opts = getGlobalOpts(this);
62
+ try {
63
+ const res = await getClient(opts).getDocument(docId);
64
+ if (!res.ok)
65
+ throw new Error(`HTTP ${res.status}`);
66
+ output(res.data, opts.format, opts.quiet);
67
+ }
68
+ catch (e) {
69
+ outputError(e, opts.quiet);
70
+ }
71
+ });
72
+ slides
73
+ .command('rename <doc-id> <title>')
74
+ .description('Rename a slide deck')
75
+ .action(async function (docId, title) {
76
+ const opts = getGlobalOpts(this);
77
+ if (opts.dryRun) {
78
+ printDryRun(getConfig(opts), 'PATCH', `/documents/${docId}`, { title });
79
+ return;
80
+ }
81
+ try {
82
+ const res = await getClient(opts).updateDocument(docId, title);
83
+ if (!res.ok)
84
+ throw new Error(`HTTP ${res.status}`);
85
+ output(res.data, opts.format, opts.quiet);
86
+ }
87
+ catch (e) {
88
+ outputError(e, opts.quiet);
89
+ }
90
+ });
91
+ slides
92
+ .command('delete <doc-id>')
93
+ .description('Delete a slide deck')
94
+ .action(async function (docId) {
95
+ const opts = getGlobalOpts(this);
96
+ if (opts.dryRun) {
97
+ printDryRun(getConfig(opts), 'DELETE', `/documents/${docId}`);
98
+ return;
99
+ }
100
+ try {
101
+ const res = await getClient(opts).deleteDocument(docId);
102
+ if (!res.ok)
103
+ throw new Error(`HTTP ${res.status}`);
104
+ output(res.data, opts.format, opts.quiet);
105
+ }
106
+ catch (e) {
107
+ outputError(e, opts.quiet);
108
+ }
109
+ });
110
+ slides
111
+ .command('content <doc-id>')
112
+ .description('Read deck content as JSON, Markdown, or plain text')
113
+ // NOTE: `--format` is intentionally not redeclared here — the global
114
+ // `--format` option catches the user's value (see the same comment
115
+ // on `docs content`). We read `opts.format` and validate it through
116
+ // `parseSlidesContentFormat`.
117
+ .option('--notes', 'Include speaker notes (md/text)', false)
118
+ .option('--out <file>', 'Output file (- for stdout)')
119
+ .option('--force', 'Overwrite existing output file', false)
120
+ .action(async function (docId) {
121
+ const opts = getGlobalOpts(this);
122
+ const local = this.opts();
123
+ try {
124
+ const format = parseSlidesContentFormat(opts.format);
125
+ if (opts.dryRun) {
126
+ printDryRun(getConfig(opts), 'GET', `/documents/${docId}/content`);
127
+ return;
128
+ }
129
+ const res = await getClient(opts).getSlidesContent(docId);
130
+ if (!res.ok) {
131
+ const body = res.data;
132
+ if (body?.error) {
133
+ // Surface backend-shaped errors (e.g., TYPE_MISMATCH) verbatim
134
+ // so agents reading stderr can act on the `code` field.
135
+ console.error(JSON.stringify(body, null, 2));
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ throw new Error(`HTTP ${res.status}`);
140
+ }
141
+ runSlidesContent({
142
+ deck: res.data,
143
+ format,
144
+ notes: local.notes,
145
+ out: local.out,
146
+ force: local.force,
147
+ quiet: opts.quiet,
148
+ });
149
+ }
150
+ catch (e) {
151
+ outputError(e, opts.quiet);
152
+ }
153
+ });
154
+ slides
155
+ .command('export <doc-id> <file>')
156
+ .description('Export a slide deck to PPTX')
157
+ // NOTE: `--format` is intentionally not redeclared here — the global
158
+ // `--format` option catches the user's value (see the same comment on
159
+ // `docs export`). We read `opts.format` via `getOptionValueSourceWithGlobals`
160
+ // to distinguish an explicit CLI flag from the default, then validate
161
+ // that only "pptx" is accepted.
162
+ .option('--force', 'Overwrite existing output file', false)
163
+ .action(async function (docId, file) {
164
+ const opts = getGlobalOpts(this);
165
+ const local = this.opts();
166
+ try {
167
+ const formatSource = this.getOptionValueSourceWithGlobals('format');
168
+ // Widen to `string` — `opts.format` is the global `OutputFormat`
169
+ // union (json|table|csv|yaml), which has no overlap with the
170
+ // export-only `pptx` value, so a direct comparison is a tsc error.
171
+ const fmt = formatSource === 'cli' ? opts.format : undefined;
172
+ if (fmt && fmt !== 'pptx')
173
+ throw new Error(`Invalid --format "${fmt}". Only "pptx" is supported.`);
174
+ if (!fmt && extname(file).toLowerCase() !== '.pptx') {
175
+ throw new Error(`Cannot infer format from "${file}". Use a .pptx extension or --format pptx.`);
176
+ }
177
+ if (opts.dryRun) {
178
+ printDryRun(getConfig(opts), 'GET', `/documents/${docId}/content`);
179
+ return;
180
+ }
181
+ const res = await getClient(opts).getSlidesContent(docId);
182
+ if (!res.ok) {
183
+ const body = res.data;
184
+ if (body?.error) {
185
+ console.error(JSON.stringify(body, null, 2));
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ throw new Error(`HTTP ${res.status}`);
190
+ }
191
+ const imageFetcher = createImageFetcher({ serverBase: getConfig(opts).server });
192
+ const bytes = await exportPptxCli(res.data, { imageFetcher });
193
+ writeBinary(bytes, file, { force: local.force, quiet: opts.quiet });
194
+ }
195
+ catch (e) {
196
+ outputError(e, opts.quiet);
197
+ }
198
+ });
10
199
  registerSlidesImportCommand(slides);
11
200
  }
12
201
  export function registerSlidesImportCommand(slides) {
@@ -141,6 +141,102 @@ const registry = [
141
141
  ],
142
142
  aliases: ['doc.import', 'document.import', 'documents.import'],
143
143
  },
144
+ // Slides (presentation) namespace
145
+ {
146
+ name: 'slides.list',
147
+ description: 'List slide decks in workspace',
148
+ safety: 'read-only',
149
+ parameters: {},
150
+ response: { type: 'array', items: { id: 'string', title: 'string', type: 'string', createdAt: 'string' } },
151
+ aliases: ['slide.list', 'deck.list', 'decks.list'],
152
+ },
153
+ {
154
+ name: 'slides.create',
155
+ description: 'Create a new slide deck',
156
+ safety: 'write',
157
+ parameters: {
158
+ title: { type: 'string', required: true, description: 'Deck title' },
159
+ },
160
+ response: { id: 'string', title: 'string', type: 'string' },
161
+ aliases: ['slide.create', 'deck.create', 'decks.create'],
162
+ },
163
+ {
164
+ name: 'slides.get',
165
+ description: 'Show slide deck metadata',
166
+ safety: 'read-only',
167
+ parameters: {
168
+ 'doc-id': { type: 'string', required: true, description: 'Document ID' },
169
+ },
170
+ response: { id: 'string', title: 'string', type: 'string', createdAt: 'string' },
171
+ aliases: ['slide.get', 'deck.get', 'decks.get'],
172
+ },
173
+ {
174
+ name: 'slides.rename',
175
+ description: 'Rename a slide deck',
176
+ safety: 'write',
177
+ parameters: {
178
+ 'doc-id': { type: 'string', required: true, description: 'Document ID' },
179
+ title: { type: 'string', required: true, description: 'New title' },
180
+ },
181
+ response: { id: 'string', title: 'string' },
182
+ aliases: ['slide.rename', 'deck.rename', 'decks.rename'],
183
+ },
184
+ {
185
+ name: 'slides.delete',
186
+ description: 'Delete a slide deck',
187
+ safety: 'destructive',
188
+ parameters: {
189
+ 'doc-id': { type: 'string', required: true, description: 'Document ID' },
190
+ },
191
+ response: { id: 'string' },
192
+ aliases: ['slide.delete', 'deck.delete', 'decks.delete'],
193
+ },
194
+ {
195
+ name: 'slides.content',
196
+ description: 'Read deck content as JSON, Markdown, or plain text',
197
+ safety: 'read-only',
198
+ parameters: {
199
+ 'doc-id': { type: 'string', required: true, description: 'Document ID' },
200
+ '--format': { type: 'string', required: false, description: 'Output format (json|md|text)', default: 'json' },
201
+ '--notes': { type: 'boolean', required: false, description: 'Include speaker notes (md/text)', default: 'false' },
202
+ '--out': { type: 'string', required: false, description: 'Output file (- for stdout)' },
203
+ '--force': { type: 'boolean', required: false, description: 'Overwrite existing output file', default: 'false' },
204
+ },
205
+ response: { type: 'object', description: 'Deck JSON, Markdown text, or plaintext per --format' },
206
+ aliases: ['slide.content', 'deck.content', 'decks.content'],
207
+ },
208
+ {
209
+ name: 'slides.import',
210
+ description: 'Import a .pptx file as a new (or replacement) slide deck',
211
+ // Default safety is `write` (create new deck); `--replace` flips it
212
+ // to destructive, mirroring `docs.import`.
213
+ safety: 'write',
214
+ parameters: {
215
+ file: { type: 'string', required: true, description: 'Source .pptx path or - for stdin' },
216
+ '--title': { type: 'string', required: false, description: 'Deck title (default: file basename)' },
217
+ '--replace': { type: 'string', required: false, description: 'Existing deck ID to replace' },
218
+ '--yes': { type: 'boolean', required: false, description: 'Skip --replace confirmation', default: 'false' },
219
+ },
220
+ response: { id: 'string', title: 'string', replaced: 'boolean' },
221
+ variants: [
222
+ { when: 'default', safety: 'write', creates: 'new slide deck' },
223
+ { when: '--replace given', safety: 'destructive', modifies: 'existing deck content' },
224
+ ],
225
+ aliases: ['slide.import', 'deck.import', 'decks.import'],
226
+ },
227
+ {
228
+ name: 'slides.export',
229
+ description: 'Export a slide deck to PPTX',
230
+ safety: 'read-only',
231
+ parameters: {
232
+ 'doc-id': { type: 'string', required: true, description: 'Document ID' },
233
+ file: { type: 'string', required: true, description: 'Output path or - for stdout' },
234
+ '--format': { type: 'string', required: false, description: 'Output format (pptx); default from extension' },
235
+ '--force': { type: 'boolean', required: false, description: 'Overwrite existing output file', default: 'false' },
236
+ },
237
+ response: { type: 'binary', description: 'PPTX bytes' },
238
+ aliases: ['slide.export', 'deck.export', 'decks.export'],
239
+ },
144
240
  // Sheets namespace — canonical names live under sheets.*
145
241
  {
146
242
  name: 'sheets.tabs.list',
@@ -0,0 +1,41 @@
1
+ import { type SlidesDocument } from '@wafflebase/slides/node';
2
+ export declare const VALID_SLIDES_CONTENT_FORMATS: readonly ["json", "md", "text"];
3
+ export type SlidesContentFormat = (typeof VALID_SLIDES_CONTENT_FORMATS)[number];
4
+ export declare const SLIDES_LOSSY_NOTICE: string;
5
+ export declare function parseSlidesContentFormat(value: string): SlidesContentFormat;
6
+ /**
7
+ * Surface for `runSlidesContent` to talk back to the CLI without taking
8
+ * a direct dependency on `process.stdout` / `process.stderr` / `fs`.
9
+ * Mirrors `ContentIO` in `../docs/content.ts`; the command wires the
10
+ * real implementations, tests inject in-memory collectors.
11
+ */
12
+ export interface SlidesContentIO {
13
+ stdout: (text: string) => void;
14
+ stderr: (line: string) => void;
15
+ /** Write `text` to `path`. Throws if the file exists and `force` is false. */
16
+ writeFile: (path: string, text: string, force: boolean) => void;
17
+ }
18
+ export declare const defaultSlidesContentIO: SlidesContentIO;
19
+ export interface RunSlidesContentArgs {
20
+ deck: SlidesDocument;
21
+ format: SlidesContentFormat;
22
+ /** Include speaker notes (`Slide.notes`) after each slide's body. */
23
+ notes?: boolean;
24
+ out?: string;
25
+ force?: boolean;
26
+ quiet?: boolean;
27
+ }
28
+ /**
29
+ * Pure orchestration for `slides content`: takes an already-fetched
30
+ * `SlidesDocument` plus user flags, produces the rendered output, and
31
+ * routes it through the supplied IO surface.
32
+ *
33
+ * - `json`: the raw deck JSON (lossless — themes, layouts, geometry,
34
+ * styling all preserved).
35
+ * - `md` / `text`: per-slide text extraction only. Slides have no single
36
+ * linear text stream, so we walk each slide's elements in document
37
+ * order, pull every `TextBody`'s blocks (text boxes, shape labels,
38
+ * table cells), and serialize them with the docs serializers. Shapes,
39
+ * images, connectors, positioning and theming are dropped.
40
+ */
41
+ export declare function runSlidesContent(args: RunSlidesContentArgs, io?: SlidesContentIO): void;
@@ -0,0 +1,122 @@
1
+ import { writeFileSync, existsSync } from 'node:fs';
2
+ import { serializeMarkdown, serializeText, } from '@wafflebase/docs';
3
+ import { flattenElements, } from '@wafflebase/slides/node';
4
+ export const VALID_SLIDES_CONTENT_FORMATS = ['json', 'md', 'text'];
5
+ export const SLIDES_LOSSY_NOTICE = 'Lossy conversion: only text is extracted (shapes, images, connectors, ' +
6
+ 'layout, theming, and positioning are dropped). See cli.md for details.';
7
+ export function parseSlidesContentFormat(value) {
8
+ if (!VALID_SLIDES_CONTENT_FORMATS.includes(value)) {
9
+ throw new Error(`Invalid --format "${value}". Use one of: ${VALID_SLIDES_CONTENT_FORMATS.join(', ')}.`);
10
+ }
11
+ return value;
12
+ }
13
+ export const defaultSlidesContentIO = {
14
+ stdout: (text) => {
15
+ process.stdout.write(text);
16
+ if (!text.endsWith('\n'))
17
+ process.stdout.write('\n');
18
+ },
19
+ stderr: (line) => {
20
+ console.error(line);
21
+ },
22
+ writeFile: (path, text, force) => {
23
+ if (existsSync(path) && !force) {
24
+ throw new Error(`Refusing to overwrite "${path}". Pass --force to allow overwrite.`);
25
+ }
26
+ writeFileSync(path, text.endsWith('\n') ? text : text + '\n', 'utf-8');
27
+ },
28
+ };
29
+ /**
30
+ * Pure orchestration for `slides content`: takes an already-fetched
31
+ * `SlidesDocument` plus user flags, produces the rendered output, and
32
+ * routes it through the supplied IO surface.
33
+ *
34
+ * - `json`: the raw deck JSON (lossless — themes, layouts, geometry,
35
+ * styling all preserved).
36
+ * - `md` / `text`: per-slide text extraction only. Slides have no single
37
+ * linear text stream, so we walk each slide's elements in document
38
+ * order, pull every `TextBody`'s blocks (text boxes, shape labels,
39
+ * table cells), and serialize them with the docs serializers. Shapes,
40
+ * images, connectors, positioning and theming are dropped.
41
+ */
42
+ export function runSlidesContent(args, io = defaultSlidesContentIO) {
43
+ const { deck, format, notes = false, out, force = false, quiet = false } = args;
44
+ let text;
45
+ if (format === 'json') {
46
+ text = JSON.stringify(deck, null, 2);
47
+ }
48
+ else {
49
+ if (!quiet)
50
+ io.stderr(SLIDES_LOSSY_NOTICE);
51
+ text = serializeDeck(deck, format, notes);
52
+ }
53
+ if (!out || out === '-') {
54
+ io.stdout(text);
55
+ return;
56
+ }
57
+ io.writeFile(out, text, force);
58
+ if (!quiet)
59
+ io.stderr(`Wrote to ${out}`);
60
+ }
61
+ /**
62
+ * Render every slide as a `## Slide N` (md) / `Slide N` (text) section
63
+ * followed by the slide's extracted text. Sections are joined with a
64
+ * blank line so the stream reads as one document.
65
+ */
66
+ function serializeDeck(deck, format, notes) {
67
+ const sections = deck.slides.map((slide, i) => serializeSlide(slide, i + 1, format, notes));
68
+ return sections.join('\n\n');
69
+ }
70
+ function serializeSlide(slide, number, format, notes) {
71
+ const blocks = collectSlideBlocks(slide);
72
+ const body = serializeBlocks(blocks, format);
73
+ const parts = [];
74
+ parts.push(format === 'md' ? `## Slide ${number}` : `Slide ${number}`);
75
+ if (body.length > 0)
76
+ parts.push(body);
77
+ if (notes && slide.notes.length > 0) {
78
+ const notesBody = serializeBlocks(slide.notes, format);
79
+ if (notesBody.length > 0) {
80
+ parts.push(format === 'md' ? '### Notes' : 'Notes:');
81
+ parts.push(notesBody);
82
+ }
83
+ }
84
+ return parts.join('\n\n');
85
+ }
86
+ /**
87
+ * Collect every text block from a slide's elements, in document order.
88
+ * Groups are flattened so nested text is included.
89
+ */
90
+ function collectSlideBlocks(slide) {
91
+ const blocks = [];
92
+ for (const el of flattenElements(slide.elements)) {
93
+ for (const body of textBodiesOf(el)) {
94
+ blocks.push(...body.blocks);
95
+ }
96
+ }
97
+ return blocks;
98
+ }
99
+ /** Extract every `TextBody` carried by a single element. */
100
+ function textBodiesOf(el) {
101
+ switch (el.type) {
102
+ case 'text':
103
+ // `TextElement.data` is a `TextBody` (intersection with fill/stroke).
104
+ return [el.data];
105
+ case 'shape':
106
+ return el.data.text ? [el.data.text] : [];
107
+ case 'table':
108
+ return el.data.rows.flatMap((row) => row.cells.map((cell) => cell.body));
109
+ default:
110
+ // image / connector carry no text; group is handled by flatten.
111
+ return [];
112
+ }
113
+ }
114
+ /**
115
+ * Serialize a flat `Block[]` via the docs serializers by wrapping them
116
+ * in a synthetic `Document`. The serializers read only `doc.blocks`
117
+ * (header/footer are opt-in and never set here), so the cast is safe.
118
+ */
119
+ function serializeBlocks(blocks, format) {
120
+ const doc = { blocks };
121
+ return format === 'md' ? serializeMarkdown(doc) : serializeText(doc);
122
+ }
@@ -144,7 +144,6 @@ function summariseReport(report) {
144
144
  // empty parts and the standard "no fallbacks" line.
145
145
  return {
146
146
  groupsFlattened: report.groupsFlattened,
147
- shadowsDropped: report.shadowsDropped,
148
147
  textBoxesPreScaled: report.textBoxesPreScaled,
149
148
  unknownShapes: report.unknownShapes,
150
149
  unknownLayoutTypes: report.unknownLayoutTypes,
@@ -0,0 +1,6 @@
1
+ import { type SlidesDocument } from '@wafflebase/slides/node';
2
+ export interface CliPptxExportOptions {
3
+ /** Blob-returning fetcher (the CLI's shared image fetcher). */
4
+ imageFetcher?: (url: string) => Promise<Blob>;
5
+ }
6
+ export declare function exportPptxCli(deck: SlidesDocument, opts?: CliPptxExportOptions): Promise<Uint8Array>;
@@ -0,0 +1,10 @@
1
+ import { exportPptx } from '@wafflebase/slides/node';
2
+ export async function exportPptxCli(deck, opts = {}) {
3
+ const fetchImage = opts.imageFetcher
4
+ ? async (src) => {
5
+ const blob = await opts.imageFetcher(src);
6
+ return { bytes: new Uint8Array(await blob.arrayBuffer()), mime: blob.type || 'image/png' };
7
+ }
8
+ : undefined;
9
+ return exportPptx(deck, { fetchImage });
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wafflebase/cli",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "CLI for Wafflebase spreadsheet API",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -20,14 +20,15 @@
20
20
  "open": "^11.0.0",
21
21
  "pdf-lib": "^1.17.1",
22
22
  "yaml": "^2.0.0",
23
- "@wafflebase/docs": "0.4.6",
24
- "@wafflebase/slides": "0.4.6"
23
+ "@wafflebase/docs": "0.4.8",
24
+ "@wafflebase/slides": "0.4.8"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/fontkit": "^2.0.0",
28
28
  "@types/node": "^25.4.0",
29
29
  "@vitest/coverage-v8": "4.1.8",
30
30
  "eslint": "^9.18.0",
31
+ "jszip": "^3.10.1",
31
32
  "tsx": "^4.19.0",
32
33
  "typescript": "^5.9.3",
33
34
  "vitest": "^4.1.8"