From 4e95066f5ca24eed53490ab2e262ef426615370c Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Wed, 13 May 2026 03:15:03 +0200 Subject: [PATCH 01/13] package.json: point homepage at thermal-label.github.io docs page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site already serves a /cli/ landing page — homepage was still pointing at the source repo on github.com. The docs page is the right target for the npmjs.com card. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 737389d..cbbc8fd 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "module", "author": "Mannes Brak", "license": "MIT", - "homepage": "https://github.com/thermal-label/cli", + "homepage": "https://thermal-label.github.io/cli/", "repository": { "type": "git", "url": "https://github.com/thermal-label/cli.git" From b2c17882c83791f473b253ee7c6edf5cdadc600e Mon Sep 17 00:00:00 2001 From: mannes Date: Sun, 31 May 2026 02:25:41 +0200 Subject: [PATCH 02/13] build: require usb for USB printer connections The driver -node packages dropped their direct usb dep (it now lives behind transport as an optional peer), so the cli no longer receives the usb native addon transitively. The cli opens USB connections, so it must own the addon directly. Serial/TCP-only consumers of the driver packages stay unburdened by the native build; the cli, which drives USB, requires it. --- package.json | 3 ++- pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index cbbc8fd..5a397d6 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "jpeg-js": "^0.4.4", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "usb": "^2.14.0" }, "peerDependencies": { "@thermal-label/brother-ql-node": ">=0.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3b3b23..6541d49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: pngjs: specifier: ^7.0.0 version: 7.0.0 + usb: + specifier: ^2.14.0 + version: 2.17.0 devDependencies: '@mbtech-nl/eslint-config': specifier: ^1.1.0 From ee3da0aaadadbe771a0cd0a68149de6b7b387209 Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 00:17:03 +0200 Subject: [PATCH 03/13] feat: --device, --media, --community flags --device forwards OpenOptions.deviceKey, --community OpenOptions.snmpCommunity, on status / print text / print image. --media resolves against the driver's listMedia() catalog by exact id or case-insensitive name and is passed to print() instead of detected media; drivers without a catalog say so. status renders the driver's details rows (the two-colour caveat lives there) and shows --media as the media line. Shared print runner in commands/print.ts. --- src/__tests__/print-text.test.ts | 76 ++++++++++++++++++++++++++ src/__tests__/select.test.ts | 91 ++++++++++++++++++++++++++++++++ src/__tests__/status.test.ts | 46 ++++++++++++++++ src/commands/print-image.ts | 43 +++------------ src/commands/print-text.ts | 44 ++------------- src/commands/print.ts | 76 ++++++++++++++++++++++++++ src/commands/select.ts | 42 ++++++++++++++- src/commands/status.ts | 26 +++++++-- src/index.ts | 52 +++++++++--------- 9 files changed, 391 insertions(+), 105 deletions(-) create mode 100644 src/__tests__/select.test.ts create mode 100644 src/commands/print.ts diff --git a/src/__tests__/print-text.test.ts b/src/__tests__/print-text.test.ts index 6a2d501..8ff21aa 100644 --- a/src/__tests__/print-text.test.ts +++ b/src/__tests__/print-text.test.ts @@ -231,3 +231,79 @@ describe('print text command', () => { expect(process.exitCode).toBe(1); }); }); + +describe('print text: --media and status failures', () => { + const twoColour: MediaDescriptor = { + id: 251, + name: '62mm continuous (two-colour)', + widthMm: 62, + type: 'continuous', + palette: [ + { name: 'black', rgb: [0, 0, 0] }, + { name: 'red', rgb: [255, 0, 0] }, + ], + }; + + function discoveryWith( + adapter: PrinterAdapter, + listMedia?: () => readonly MediaDescriptor[], + ): PrinterDiscovery { + const d = mockDiscovery({ adapter, printCalls: [], closeCalls: 0 }); + d.openPrinter = () => Promise.resolve(adapter); + if (listMedia) d.listMedia = listMedia; + return d; + } + + function importerFor(discovery: PrinterDiscovery) { + return (pkg: string): Promise => + pkg === '@thermal-label/brother-ql-node' + ? Promise.resolve({ discovery }) + : Promise.reject(new Error('missing')); + } + + it('passes the resolved --media to print() and ignores detected media', async () => { + const mock = mockAdapter(stdMedia); + const printedMedia: (MediaDescriptor | undefined)[] = []; + mock.adapter.print = (_image, media): Promise => { + printedMedia.push(media); + return Promise.resolve(); + }; + const discovery = discoveryWith(mock.adapter, () => [stdMedia, twoColour]); + const lines: string[] = []; + await printTextCommand('X', { + importer: importerFor(discovery), + out: s => lines.push(s), + media: '251', + }); + expect(printedMedia).toEqual([twoColour]); + expect(process.exitCode).toBe(0); + }); + + it('errors when --media names an unknown id, before printing', async () => { + const mock = mockAdapter(stdMedia); + const discovery = discoveryWith(mock.adapter, () => [stdMedia]); + const lines: string[] = []; + await printTextCommand('X', { + importer: importerFor(discovery), + out: s => lines.push(s), + media: 'nope', + }); + expect(lines.some(l => l.includes("Unknown media 'nope'"))).toBe(true); + expect(mock.printCalls).toHaveLength(0); + expect(mock.closeCalls).toBe(1); + expect(process.exitCode).toBe(1); + }); + + it('errors when the driver has no media catalog and --media is given', async () => { + const mock = mockAdapter(stdMedia); + const discovery = discoveryWith(mock.adapter); + const lines: string[] = []; + await printTextCommand('X', { + importer: importerFor(discovery), + out: s => lines.push(s), + media: '259', + }); + expect(lines.some(l => l.includes('does not expose a media catalog'))).toBe(true); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/src/__tests__/select.test.ts b/src/__tests__/select.test.ts new file mode 100644 index 0000000..2150800 --- /dev/null +++ b/src/__tests__/select.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import type { + DiscoveredPrinter, + MediaDescriptor, + OpenOptions, + PrinterAdapter, + PrinterDiscovery, +} from '@thermal-label/contracts'; + +import type { LoadedDriver } from '../discovery.js'; +import { resolveMedia, SelectionError } from '../commands/select.js'; + +function adapter(family: string, model: string): PrinterAdapter { + return { + family, + model, + connected: true, + getStatus: () => Promise.reject(new Error('not used')), + print: () => Promise.resolve(), + createPreview: () => Promise.reject(new Error('not used')), + close: () => Promise.resolve(), + }; +} + +interface FakeDriver { + discovery: PrinterDiscovery; + openCalls: OpenOptions[]; +} + +/** A driver whose `openPrinter` behaves per `open`; `listPrinters` returns `found`. */ +function fakeDriver( + family: string, + open: (opts: OpenOptions) => Promise | PrinterAdapter, + found: DiscoveredPrinter[] = [], + listMedia?: () => readonly MediaDescriptor[], +): FakeDriver { + const openCalls: OpenOptions[] = []; + const discovery: PrinterDiscovery = { + family, + listPrinters: () => Promise.resolve(found), + openPrinter: (opts?: OpenOptions) => { + openCalls.push(opts ?? {}); + // Deliberately not async: a synchronous throw must be caught too. + return Promise.resolve(open(opts ?? {})); + }, + }; + if (listMedia) discovery.listMedia = listMedia; + return { discovery, openCalls }; +} + +describe('resolveMedia', () => { + const catalog: MediaDescriptor[] = [ + { id: 259, name: '62mm continuous', widthMm: 62, type: 'continuous' }, + { id: 271, name: '29×90mm die-cut (DK-11201)', widthMm: 29, heightMm: 90, type: 'die-cut' }, + ]; + const driver: LoadedDriver = { + packageName: '@thermal-label/brother-ql-node', + discovery: fakeDriver( + 'brother-ql', + () => adapter('brother-ql', 'x'), + [], + () => catalog, + ).discovery, + }; + + it('matches by exact id', () => { + expect(resolveMedia(driver, '271').id).toBe(271); + }); + + it('matches by name, case-insensitively', () => { + expect(resolveMedia(driver, '62MM CONTINUOUS').id).toBe(259); + }); + + it('lists the catalog on an unknown id', () => { + expect(() => resolveMedia(driver, '999')).toThrow(SelectionError); + expect(() => resolveMedia(driver, '999')).toThrow( + /Unknown media '999'.*\n\s+259\s+62mm continuous/s, + ); + }); + + it('errors when the driver exposes no catalog', () => { + const bare: LoadedDriver = { + packageName: '@thermal-label/labelwriter-node', + discovery: fakeDriver('labelwriter', () => adapter('labelwriter', 'x')).discovery, + }; + expect(() => resolveMedia(bare, '259')).toThrow( + 'Driver labelwriter does not expose a media catalog', + ); + }); +}); diff --git a/src/__tests__/status.test.ts b/src/__tests__/status.test.ts index 0c7c44c..7a29d2f 100644 --- a/src/__tests__/status.test.ts +++ b/src/__tests__/status.test.ts @@ -223,6 +223,52 @@ describe('status command', () => { expect(process.exitCode).toBe(1); }); + it('renders status details rows, warnings in place', async () => { + const { out, lines } = collect(); + const { discovery } = mockDiscovery([ + { + family: 'brother-ql', + name: 'QL-820NWBc', + status: baseStatus({ + details: [ + { label: 'Printer state', value: 'idle' }, + { label: 'Two-colour', value: 'not detectable over network', severity: 'warn' }, + ], + }), + }, + ]); + const importer = (pkg: string) => + pkg === '@thermal-label/brother-ql-node' + ? Promise.resolve({ discovery }) + : Promise.reject(new Error('missing')); + await statusCommand({ importer, out }); + expect(lines.some(l => l.includes('Printer state: idle'))).toBe(true); + expect(lines.some(l => l.includes('Two-colour: not detectable over network'))).toBe(true); + }); + + it('shows --media as the media line and validates it against the catalog', async () => { + const { out, lines } = collect(); + const { discovery } = mockDiscovery([ + { family: 'brother-ql', name: 'QL-820NWBc', status: baseStatus() }, + ]); + discovery.listMedia = () => [ + { id: 251, name: '62mm continuous (two-colour)', widthMm: 62, type: 'continuous' }, + ]; + const importer = (pkg: string) => + pkg === '@thermal-label/brother-ql-node' + ? Promise.resolve({ discovery }) + : Promise.reject(new Error('missing')); + await statusCommand({ importer, out, media: '251' }); + expect( + lines.some(l => l.includes('62mm continuous (two-colour)') && l.includes('(from --media)')), + ).toBe(true); + expect(process.exitCode).toBe(0); + + await statusCommand({ importer, out, media: '999' }); + expect(lines.some(l => l.includes("Unknown media '999'"))).toBe(true); + expect(process.exitCode).toBe(1); + }); + it('uses TCP transport and prints Transport line when --host is set with --printer', async () => { const { out, lines } = collect(); const { discovery, openCalls } = mockDiscovery([ diff --git a/src/commands/print-image.ts b/src/commands/print-image.ts index f6e41a5..a18a764 100644 --- a/src/commands/print-image.ts +++ b/src/commands/print-image.ts @@ -1,15 +1,12 @@ import chalk from 'chalk'; -import { MediaNotSpecifiedError } from '@thermal-label/contracts'; - -import type { PrintOptions } from '@thermal-label/contracts'; - import type { DynamicImporter } from '../discovery.js'; import { renderImageLabel, type ImageOptions, type ReadFileFn } from '../render.js'; -import { selectPrinter, SelectionError, type PrinterSelector } from './select.js'; +import { runPrint, type OutFn } from './print.js'; +import { errorMessage, selectPrinter, SelectionError, type PrinterSelector } from './select.js'; -export type OutFn = (line: string) => void; +export type { OutFn } from './print.js'; export interface PrintImageCommandOptions extends PrinterSelector, ImageOptions { density?: string; @@ -41,7 +38,7 @@ export async function printImageCommand( try { image = await renderImageLabel(path, options, options.readFileFn); } catch (err) { - out(chalk.red(`Failed to load image: ${err instanceof Error ? err.message : String(err)}`)); + out(chalk.red(`Failed to load image: ${errorMessage(err)}`)); process.exitCode = 1; return; } @@ -50,40 +47,12 @@ export async function printImageCommand( try { printer = await selection.driver.discovery.openPrinter(selection.openOptions); } catch (err) { - out(chalk.red(`Failed to open printer: ${err instanceof Error ? err.message : String(err)}`)); + out(chalk.red(`Failed to open printer: ${errorMessage(err)}`)); process.exitCode = 1; return; } - try { - try { - await printer.getStatus(); - } catch { - // Ignore — see print-text.ts for rationale. - } - - const copies = options.copies ?? 1; - const printOpts: PrintOptions = {}; - if (options.density !== undefined) printOpts.density = options.density; - for (let i = 0; i < copies; i++) { - await printer.print(image, undefined, printOpts); - } - out(chalk.green(`Printed ${copies.toString()} label${copies === 1 ? '' : 's'}.`)); - } catch (err) { - if (err instanceof MediaNotSpecifiedError) { - out( - chalk.red( - 'No media is loaded or detected. Load media or use a driver with media auto-detection (e.g. Brother QL).', - ), - ); - process.exitCode = 1; - return; - } - out(chalk.red(`Print failed: ${err instanceof Error ? err.message : String(err)}`)); - process.exitCode = 1; - } finally { - await printer.close(); - } + await runPrint(out, selection.driver, printer, image, options); } function defaultOut(line: string): void { diff --git a/src/commands/print-text.ts b/src/commands/print-text.ts index aa8f10c..0df9390 100644 --- a/src/commands/print-text.ts +++ b/src/commands/print-text.ts @@ -1,15 +1,12 @@ import chalk from 'chalk'; -import { MediaNotSpecifiedError } from '@thermal-label/contracts'; - -import type { PrintOptions } from '@thermal-label/contracts'; - import type { DynamicImporter } from '../discovery.js'; import { renderTextLabel, type TextOptions } from '../render.js'; -import { selectPrinter, SelectionError, type PrinterSelector } from './select.js'; +import { runPrint, type OutFn } from './print.js'; +import { errorMessage, selectPrinter, SelectionError, type PrinterSelector } from './select.js'; -export type OutFn = (line: string) => void; +export type { OutFn } from './print.js'; export interface PrintTextCommandOptions extends PrinterSelector, TextOptions { density?: string; @@ -42,43 +39,12 @@ export async function printTextCommand( try { printer = await selection.driver.discovery.openPrinter(selection.openOptions); } catch (err) { - out(chalk.red(`Failed to open printer: ${err instanceof Error ? err.message : String(err)}`)); + out(chalk.red(`Failed to open printer: ${errorMessage(err)}`)); process.exitCode = 1; return; } - try { - // Warm the adapter's media cache so print() can default to detected media. - try { - await printer.getStatus(); - } catch { - // Ignore — drivers without media detection will still print if the - // user passes media via a higher-level flag (currently none). If no - // media is specified, we surface MediaNotSpecifiedError below. - } - - const copies = options.copies ?? 1; - const printOpts: PrintOptions = {}; - if (options.density !== undefined) printOpts.density = options.density; - for (let i = 0; i < copies; i++) { - await printer.print(image, undefined, printOpts); - } - out(chalk.green(`Printed ${copies.toString()} label${copies === 1 ? '' : 's'}.`)); - } catch (err) { - if (err instanceof MediaNotSpecifiedError) { - out( - chalk.red( - 'No media is loaded or detected. Load media or use a driver with media auto-detection (e.g. Brother QL).', - ), - ); - process.exitCode = 1; - return; - } - out(chalk.red(`Print failed: ${err instanceof Error ? err.message : String(err)}`)); - process.exitCode = 1; - } finally { - await printer.close(); - } + await runPrint(out, selection.driver, printer, image, options); } function defaultOut(line: string): void { diff --git a/src/commands/print.ts b/src/commands/print.ts new file mode 100644 index 0000000..905faa5 --- /dev/null +++ b/src/commands/print.ts @@ -0,0 +1,76 @@ +import chalk from 'chalk'; + +import { MediaNotSpecifiedError } from '@thermal-label/contracts'; + +import type { + MediaDescriptor, + PrinterAdapter, + PrintOptions, + RawImageData, +} from '@thermal-label/contracts'; + +import type { LoadedDriver } from '../discovery.js'; + +import { errorMessage, resolveMedia, SelectionError } from './select.js'; + +export type OutFn = (line: string) => void; + +export interface PrintRunOptions { + density?: string; + copies?: number; + /** `--media`: overrides detected media; also lets a failed status query through. */ + media?: string; +} + +/** + * The part of `print text` / `print image` after the label is rendered + * and the printer is open: media resolution, the status pre-check, the + * copies loop, and closing the printer. + */ +export async function runPrint( + out: OutFn, + driver: LoadedDriver, + printer: PrinterAdapter, + image: RawImageData, + options: PrintRunOptions, +): Promise { + try { + let media: MediaDescriptor | undefined; + if (options.media !== undefined) media = resolveMedia(driver, options.media); + + // Warm the adapter's media cache so print() can default to detected media. + try { + await printer.getStatus(); + } catch { + // Ignore — with --media the job is sized anyway; without it the + // driver raises MediaNotSpecifiedError below. + } + + const copies = options.copies ?? 1; + const printOpts: PrintOptions = {}; + if (options.density !== undefined) printOpts.density = options.density; + for (let i = 0; i < copies; i++) { + await printer.print(image, media, printOpts); + } + out(chalk.green(`Printed ${copies.toString()} label${copies === 1 ? '' : 's'}.`)); + } catch (err) { + if (err instanceof SelectionError) { + out(chalk.red(err.message)); + process.exitCode = 1; + return; + } + if (err instanceof MediaNotSpecifiedError) { + out( + chalk.red( + 'No media is loaded or detected. Load media, pass --media , or use a driver with media auto-detection (e.g. Brother QL).', + ), + ); + process.exitCode = 1; + return; + } + out(chalk.red(`Print failed: ${errorMessage(err)}`)); + process.exitCode = 1; + } finally { + await printer.close(); + } +} diff --git a/src/commands/select.ts b/src/commands/select.ts index e841cfa..67010ca 100644 --- a/src/commands/select.ts +++ b/src/commands/select.ts @@ -1,4 +1,4 @@ -import type { DiscoveredPrinter, OpenOptions } from '@thermal-label/contracts'; +import type { DiscoveredPrinter, MediaDescriptor, OpenOptions } from '@thermal-label/contracts'; import { discoverAll, @@ -13,6 +13,12 @@ export interface PrinterSelector { host?: string; port?: number; serial?: string; + /** `--device`: registry key, forwarded as `OpenOptions.deviceKey`. */ + device?: string; + /** `--media`: media id or name, resolved via `resolveMedia` after selection. */ + media?: string; + /** `--community`: SNMP community, forwarded as `OpenOptions.snmpCommunity`. */ + community?: string; } export interface SelectionResult { @@ -59,6 +65,8 @@ export async function selectPrinter( const opts: OpenOptions = { host: selector.host }; if (selector.port !== undefined) opts.port = selector.port; if (selector.serial !== undefined) opts.serialNumber = selector.serial; + if (selector.device !== undefined) opts.deviceKey = selector.device; + if (selector.community !== undefined) opts.snmpCommunity = selector.community; const [driver] = filtered; if (!driver) throw new SelectionError('Internal: no driver after filter.'); return { driver, openOptions: opts }; @@ -93,9 +101,37 @@ export async function selectPrinter( const opts: OpenOptions = {}; if (picked.serialNumber !== undefined) opts.serialNumber = picked.serialNumber; + if (selector.device !== undefined) opts.deviceKey = selector.device; + if (selector.community !== undefined) opts.snmpCommunity = selector.community; return { driver, openOptions: opts }; } +/** + * Resolve `--media` against the driver's catalog by exact id or exact + * name (case-insensitive). + */ +export function resolveMedia(driver: LoadedDriver, media: string): MediaDescriptor { + const family = driver.discovery.family; + const catalog = driver.discovery.listMedia?.(); + if (catalog === undefined) { + throw new SelectionError( + `Driver ${family} does not expose a media catalog; --media is not available for it.`, + ); + } + const wanted = media.toLowerCase(); + const found = catalog.find(m => String(m.id) === media || m.name.toLowerCase() === wanted); + if (found === undefined) { + const ids = catalog.map(m => `${String(m.id)} ${m.name}`); + throw new SelectionError( + [ + `Unknown media '${media}' for ${family}. Known media (id name):`, + ...ids.map(l => ` ${l}`), + ].join('\n'), + ); + } + return found; +} + function formatMultiple(printers: readonly DiscoveredPrinter[]): string { const lines = ['Multiple printers found:']; for (const p of printers) { @@ -107,3 +143,7 @@ function formatMultiple(printers: readonly DiscoveredPrinter[]): string { lines.push('', 'Use --printer or --serial to pick one.'); return lines.join('\n'); } + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/commands/status.ts b/src/commands/status.ts index 29b1195..e09a69f 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -4,7 +4,7 @@ import type { MediaDescriptor, PrinterAdapter, PrinterStatus } from '@thermal-la import type { DynamicImporter } from '../discovery.js'; -import { selectPrinter, SelectionError, type PrinterSelector } from './select.js'; +import { resolveMedia, selectPrinter, SelectionError, type PrinterSelector } from './select.js'; export type OutFn = (line: string) => void; @@ -38,8 +38,17 @@ export async function statusCommand(options: StatusCommandOptions = {}): Promise } try { + const explicitMedia = + options.media === undefined ? undefined : resolveMedia(selection.driver, options.media); const status = await printer.getStatus(); - printStatus(out, printer, status, options.host, options.port); + printStatus(out, printer, status, explicitMedia, options.host, options.port); + } catch (err) { + if (err instanceof SelectionError) { + out(chalk.red(err.message)); + process.exitCode = 1; + return; + } + throw err; } finally { await printer.close(); } @@ -49,6 +58,7 @@ function printStatus( out: OutFn, printer: PrinterAdapter, status: PrinterStatus, + explicitMedia?: MediaDescriptor, host?: string, port?: number, ): void { @@ -56,7 +66,11 @@ function printStatus( out( `${chalk.bold('Status:')} ${status.ready ? chalk.green('Ready') : chalk.yellow('Not ready')}`, ); - out(`${chalk.bold('Media:')} ${formatMedia(status.mediaLoaded, status.detectedMedia)}`); + if (explicitMedia === undefined) { + out(`${chalk.bold('Media:')} ${formatMedia(status.mediaLoaded, status.detectedMedia)}`); + } else { + out(`${chalk.bold('Media:')} ${formatMedia(true, explicitMedia)} (from --media)`); + } if (status.errors.length === 0) { out(`${chalk.bold('Errors:')} none`); } else { @@ -65,12 +79,16 @@ function printStatus( out(` - [${e.code}] ${e.message}`); } } + for (const d of status.details ?? []) { + const line = ` ${d.label}: ${d.value}`; + out(d.severity === 'warn' ? chalk.yellow(line) : line); + } if (host !== undefined) { out(`${chalk.bold('Transport:')} TCP ${host}:${(port ?? 9100).toString()}`); } } -function formatMedia(mediaLoaded: boolean, media?: MediaDescriptor): string { +export function formatMedia(mediaLoaded: boolean, media?: MediaDescriptor): string { if (!media) { return mediaLoaded ? 'loaded (details not detected)' : 'not detected'; } diff --git a/src/index.ts b/src/index.ts index 60b4b55..71f9eda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,9 @@ interface CommanderStatusOpts { host?: string; port?: number; serial?: string; + device?: string; + media?: string; + community?: string; } interface CommanderPrintTextOpts extends CommanderStatusOpts { @@ -53,6 +56,9 @@ function buildStatusOptions(opts: CommanderStatusOpts): StatusCommandOptions { if (opts.host !== undefined) out.host = opts.host; if (opts.port !== undefined) out.port = opts.port; if (opts.serial !== undefined) out.serial = opts.serial; + if (opts.device !== undefined) out.device = opts.device; + if (opts.media !== undefined) out.media = opts.media; + if (opts.community !== undefined) out.community = opts.community; return out; } @@ -77,6 +83,21 @@ function buildPrintImageOptions(opts: CommanderPrintImageOpts): PrintImageComman return out; } +/** Selection flags shared by `status`, `print text` and `print image`. */ +function withSelectionFlags(cmd: Command): Command { + return cmd + .option('--printer ', 'Filter by driver family (brother-ql, labelwriter, labelmanager)') + .option('--host ', 'Connect via TCP to the given host') + .option('--port ', 'TCP port (default 9100)', parseIntArg) + .option('--serial ', 'Filter by serial number') + .option( + '--device ', + 'Registry key of the model (e.g. QL_820NWBc) when the driver cannot identify it', + ) + .option('--media ', "Media id or name from the driver's catalog; overrides detected media") + .option('--community ', 'SNMP community for network printers (default public)'); +} + export function buildProgram(): Command { const program = new Command(); program @@ -94,26 +115,15 @@ export function buildProgram(): Command { await listCommand(cmdOpts); }); - program - .command('status') - .description('Query the status of a connected printer.') - .option('--printer ', 'Filter by driver family (brother-ql, labelwriter, labelmanager)') - .option('--host ', 'Connect via TCP to the given host') - .option('--port ', 'TCP port (default 9100)', parseIntArg) - .option('--serial ', 'Filter by serial number') - .action(async (opts: CommanderStatusOpts) => { - await statusCommand(buildStatusOptions(opts)); - }); + withSelectionFlags( + program.command('status').description('Query the status of a connected printer.'), + ).action(async (opts: CommanderStatusOpts) => { + await statusCommand(buildStatusOptions(opts)); + }); const print = program.command('print').description('Print labels.'); - print - .command('text ') - .description('Render text to a label and print.') - .option('--printer ', 'Filter by driver family') - .option('--host ', 'Connect via TCP to the given host') - .option('--port ', 'TCP port (default 9100)', parseIntArg) - .option('--serial ', 'Filter by serial number') + withSelectionFlags(print.command('text ').description('Render text to a label and print.')) .option('--invert', 'White text on black background') .option('--scale-x ', 'Horizontal scale factor', parseIntArg) .option('--scale-y ', 'Vertical scale factor', parseIntArg) @@ -123,13 +133,7 @@ export function buildProgram(): Command { await printTextCommand(text, buildPrintTextOptions(opts)); }); - print - .command('image ') - .description('Load an image file and print.') - .option('--printer ', 'Filter by driver family') - .option('--host ', 'Connect via TCP to the given host') - .option('--port ', 'TCP port (default 9100)', parseIntArg) - .option('--serial ', 'Filter by serial number') + withSelectionFlags(print.command('image ').description('Load an image file and print.')) .option('--threshold ', '1bpp threshold (0-255)', parseIntArg) .option('--dither', 'Floyd-Steinberg dithering') .option('--invert', 'Invert colours') From 6fdbcac98538a9c360544efb095bc7602f6233fd Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 00:17:29 +0200 Subject: [PATCH 04/13] feat(select): --host walks drivers, every failure declines; network printers from list --host without --printer no longer errors out. selectPrinter walks the installed drivers in KNOWN_DRIVERS order and calls openPrinter inside one try/catch; any failure, typed or not, is a decline and the walk moves on, so a driver that cannot speak to the address (labelwriter-node throws a plain Error on TCP without deviceKey) never takes down a print on another driver. Only when every driver declines does the CLI fail, listing family: message per driver; a DeviceIdentificationRequiredError renders its candidates and a copy-pasteable --printer/--device invocation (--media added when the driver says status is unavailable too). Sequential on purpose: one 9100 socket at a time. selectPrinter now returns the opened printer; commands stop opening themselves. Discovered network printers re-open by host/port with the discovered device key so identify does not run twice. --printer keeps surfacing that one driver's error verbatim. --- src/__tests__/select.test.ts | 236 ++++++++++++++++++++++++++++++++++- src/__tests__/status.test.ts | 11 +- src/commands/print-image.ts | 35 +++--- src/commands/print-text.ts | 23 ++-- src/commands/select.ts | 141 ++++++++++++++++++--- src/commands/status.ts | 12 +- 6 files changed, 390 insertions(+), 68 deletions(-) diff --git a/src/__tests__/select.test.ts b/src/__tests__/select.test.ts index 2150800..ddeed3f 100644 --- a/src/__tests__/select.test.ts +++ b/src/__tests__/select.test.ts @@ -1,15 +1,21 @@ import { describe, expect, it } from 'vitest'; import type { + DeviceEntry, DiscoveredPrinter, MediaDescriptor, OpenOptions, PrinterAdapter, PrinterDiscovery, } from '@thermal-label/contracts'; +import { DeviceIdentificationRequiredError } from '@thermal-label/contracts'; -import type { LoadedDriver } from '../discovery.js'; -import { resolveMedia, SelectionError } from '../commands/select.js'; +import type { DynamicImporter, LoadedDriver } from '../discovery.js'; +import { resolveMedia, selectPrinter, SelectionError } from '../commands/select.js'; + +function device(key: string, name: string, family: string): DeviceEntry { + return { key, name, family, transports: {}, engines: [], support: { status: 'untested' } }; +} function adapter(family: string, model: string): PrinterAdapter { return { @@ -49,6 +55,232 @@ function fakeDriver( return { discovery, openCalls }; } +/** Like `fakeDriver` but `openPrinter` throws synchronously. */ +function throwingDriver(family: string, error: unknown): FakeDriver { + const openCalls: OpenOptions[] = []; + return { + openCalls, + discovery: { + family, + listPrinters: () => Promise.resolve([]), + openPrinter: (opts?: OpenOptions) => { + openCalls.push(opts ?? {}); + throw error; + }, + }, + }; +} + +function importerFor(drivers: Record): DynamicImporter { + return (pkg: string) => { + const short = pkg.replace('@thermal-label/', '').replace('-node', ''); + const d = drivers[short]; + return d ? Promise.resolve({ discovery: d.discovery }) : Promise.reject(new Error('missing')); + }; +} + +const QL = device('QL_820NWBc', 'QL-820NWBc', 'brother-ql'); +const PT = device('PT_E550W', 'PT-E550W', 'brother-ql'); +const LW = device('LW_550', 'LabelWriter 550', 'labelwriter'); + +async function failure(p: Promise): Promise { + try { + await p; + } catch (err) { + if (err instanceof SelectionError) return err; + throw err; + } + throw new Error('expected a SelectionError'); +} + +describe('selectPrinter with --host', () => { + it('walks drivers in order; a plain Error from the first is a decline, the second opens', async () => { + const brotherQl = fakeDriver('brother-ql', () => { + throw new Error('no SNMP answer from 10.0.0.9'); + }); + const labelwriter = fakeDriver('labelwriter', () => adapter('labelwriter', 'LabelWriter 550')); + const importer = importerFor({ 'brother-ql': brotherQl, labelwriter }); + + const result = await selectPrinter({ host: '10.0.0.9', port: 9100 }, importer); + + expect(result.driver.discovery.family).toBe('labelwriter'); + expect(result.printer.model).toBe('LabelWriter 550'); + expect(brotherQl.openCalls).toEqual([{ host: '10.0.0.9', port: 9100 }]); + expect(labelwriter.openCalls).toEqual([{ host: '10.0.0.9', port: 9100 }]); + }); + + it('treats a synchronous throw from openPrinter as a decline', async () => { + const brotherQl = throwingDriver('brother-ql', 'not even an Error'); + const labelwriter = fakeDriver('labelwriter', () => adapter('labelwriter', 'LabelWriter 550')); + const importer = importerFor({ 'brother-ql': brotherQl, labelwriter }); + + const result = await selectPrinter({ host: '10.0.0.9' }, importer); + expect(result.driver.discovery.family).toBe('labelwriter'); + }); + + it('stops at the first driver that opens; later drivers are not asked', async () => { + const brotherQl = fakeDriver('brother-ql', () => adapter('brother-ql', 'QL-820NWBc')); + const labelwriter = fakeDriver('labelwriter', () => adapter('labelwriter', 'LabelWriter 550')); + const importer = importerFor({ 'brother-ql': brotherQl, labelwriter }); + + const result = await selectPrinter({ host: '10.0.0.9' }, importer); + expect(result.printer.model).toBe('QL-820NWBc'); + expect(labelwriter.openCalls).toEqual([]); + }); + + it('lists every decline when all drivers fail, rendering identification candidates and the --device line', async () => { + const idErr = new DeviceIdentificationRequiredError([QL, PT], () => + Promise.reject(new Error('not used')), + ); + idErr.message = + 'no SNMP answer from 10.0.0.9; pass deviceKey, and media, since status is unavailable too'; + const brotherQl = fakeDriver('brother-ql', () => { + throw idErr; + }); + const labelwriter = fakeDriver('labelwriter', () => { + throw new Error('TCP open requires `deviceKey`'); + }); + const importer = importerFor({ 'brother-ql': brotherQl, labelwriter }); + + const err = await failure(selectPrinter({ host: '10.0.0.9', invocation: 'status' }, importer)); + const msg = err.message; + expect(msg).toContain('No installed driver could open 10.0.0.9:'); + expect(msg).toContain('brother-ql: no SNMP answer from 10.0.0.9'); + expect(msg).toContain('labelwriter: TCP open requires `deviceKey`'); + expect(msg).toMatch(/QL_820NWBc\s+QL-820NWBc/); + expect(msg).toMatch(/PT_E550W\s+PT-E550W/); + expect(msg).toContain( + 'thermal-label status --host 10.0.0.9 --printer brother-ql --device QL_820NWBc --media ', + ); + expect(msg).toContain('--printer '); + }); + + it('omits --media from the copy line when the driver identified the model as unknown', async () => { + const idErr = new DeviceIdentificationRequiredError([QL], () => + Promise.reject(new Error('not used')), + ); + idErr.message = 'model "Brother HL-L2350DW" not in the brother-ql registry'; + const brotherQl = fakeDriver('brother-ql', () => { + throw idErr; + }); + const importer = importerFor({ 'brother-ql': brotherQl }); + + const err = await failure( + selectPrinter({ host: '10.0.0.9', port: 9101, invocation: 'print text "hi"' }, importer), + ); + expect(err.message).toContain( + 'thermal-label print text "hi" --host 10.0.0.9 --port 9101 --printer brother-ql --device QL_820NWBc', + ); + expect(err.message).not.toContain('--media'); + }); + + it("with --printer surfaces that one driver's error verbatim and does not walk", async () => { + const brotherQl = fakeDriver('brother-ql', () => { + throw new Error('ECONNREFUSED 10.0.0.9:9100'); + }); + const labelwriter = fakeDriver('labelwriter', () => adapter('labelwriter', 'LabelWriter 550')); + const importer = importerFor({ 'brother-ql': brotherQl, labelwriter }); + + const err = await failure(selectPrinter({ host: '10.0.0.9', printer: 'brother-ql' }, importer)); + expect(err.message).toContain('Failed to open printer at 10.0.0.9:'); + expect(err.message).toContain('brother-ql: ECONNREFUSED 10.0.0.9:9100'); + expect(err.message).not.toContain('No installed driver'); + expect(labelwriter.openCalls).toEqual([]); + }); + + it('forwards --device and --community to every driver in the walk', async () => { + const brotherQl = fakeDriver('brother-ql', () => { + throw new Error('Unknown deviceKey "LW_550"'); + }); + const labelwriter = fakeDriver('labelwriter', () => adapter('labelwriter', 'LabelWriter 550')); + const importer = importerFor({ 'brother-ql': brotherQl, labelwriter }); + + const result = await selectPrinter( + { host: '10.0.0.9', device: 'LW_550', community: 'lab', serial: 'S1' }, + importer, + ); + const expected: OpenOptions = { + host: '10.0.0.9', + serialNumber: 'S1', + deviceKey: 'LW_550', + snmpCommunity: 'lab', + }; + expect(brotherQl.openCalls).toEqual([expected]); + expect(labelwriter.openCalls).toEqual([expected]); + expect(result.driver.discovery.family).toBe('labelwriter'); + }); +}); + +describe('selectPrinter via discovery', () => { + const networkQl: DiscoveredPrinter = { + device: QL, + transport: 'tcp', + connectionId: 'opaque-do-not-parse', + host: '192.168.1.67', + port: 9100, + serialNumber: 'M5G679125', + }; + const usbLw: DiscoveredPrinter = { + device: LW, + transport: 'usb', + connectionId: '1.4', + serialNumber: 'LW1', + }; + + it('re-opens a network printer by host/port with the discovered device key', async () => { + const brotherQl = fakeDriver('brother-ql', () => adapter('brother-ql', 'QL-820NWBc'), [ + networkQl, + ]); + const importer = importerFor({ 'brother-ql': brotherQl }); + + const result = await selectPrinter({}, importer); + expect(result.printer.model).toBe('QL-820NWBc'); + expect(brotherQl.openCalls).toEqual([ + { serialNumber: 'M5G679125', host: '192.168.1.67', port: 9100, deviceKey: 'QL_820NWBc' }, + ]); + }); + + it('lets --device override the discovered key and passes --community on re-open', async () => { + const brotherQl = fakeDriver('brother-ql', () => adapter('brother-ql', 'PT-E550W'), [ + networkQl, + ]); + const importer = importerFor({ 'brother-ql': brotherQl }); + + await selectPrinter({ device: 'PT_E550W', community: 'lab' }, importer); + expect(brotherQl.openCalls[0]).toMatchObject({ deviceKey: 'PT_E550W', snmpCommunity: 'lab' }); + }); + + it('re-opens a USB printer by serial only, adding deviceKey only when --device is given', async () => { + const labelwriter = fakeDriver('labelwriter', () => adapter('labelwriter', 'LabelWriter 550'), [ + usbLw, + ]); + const importer = importerFor({ labelwriter }); + + await selectPrinter({}, importer); + await selectPrinter({ device: 'LW_550' }, importer); + expect(labelwriter.openCalls).toEqual([ + { serialNumber: 'LW1' }, + { serialNumber: 'LW1', deviceKey: 'LW_550' }, + ]); + }); + + it('reports an open failure on a discovered printer without the walk hint', async () => { + const labelwriter = fakeDriver( + 'labelwriter', + () => { + throw new Error('USB permission denied'); + }, + [usbLw], + ); + const importer = importerFor({ labelwriter }); + + const err = await failure(selectPrinter({}, importer)); + expect(err.message).toContain('Failed to open printer:'); + expect(err.message).toContain('labelwriter: USB permission denied'); + expect(err.message).not.toContain('--printer '); + }); +}); + describe('resolveMedia', () => { const catalog: MediaDescriptor[] = [ { id: 259, name: '62mm continuous', widthMm: 62, type: 'continuous' }, diff --git a/src/__tests__/status.test.ts b/src/__tests__/status.test.ts index 7a29d2f..1d1b696 100644 --- a/src/__tests__/status.test.ts +++ b/src/__tests__/status.test.ts @@ -209,18 +209,19 @@ describe('status command', () => { expect(lines.some(l => l.includes('LabelWriter 450'))).toBe(true); }); - it('errors when --host is given without --printer', async () => { + it('walks the installed drivers when --host is given without --printer', async () => { const { out, lines } = collect(); - const { discovery } = mockDiscovery([ + const { discovery, openCalls } = mockDiscovery([ { family: 'brother-ql', name: 'QL-820NWB', status: baseStatus() }, ]); const importer = (pkg: string) => pkg === '@thermal-label/brother-ql-node' ? Promise.resolve({ discovery }) : Promise.reject(new Error('missing')); - await statusCommand({ importer, out, host: '192.168.1.42' }); - expect(lines.some(l => l.includes('--printer') && l.includes('--host'))).toBe(true); - expect(process.exitCode).toBe(1); + await statusCommand({ importer, out, host: '192.168.1.42', community: 'lab' }); + expect(openCalls).toEqual([{ host: '192.168.1.42', snmpCommunity: 'lab' }]); + expect(lines.some(l => l.includes('QL-820NWB'))).toBe(true); + expect(process.exitCode).toBe(0); }); it('renders status details rows, warnings in place', async () => { diff --git a/src/commands/print-image.ts b/src/commands/print-image.ts index a18a764..dffe7b9 100644 --- a/src/commands/print-image.ts +++ b/src/commands/print-image.ts @@ -4,7 +4,7 @@ import type { DynamicImporter } from '../discovery.js'; import { renderImageLabel, type ImageOptions, type ReadFileFn } from '../render.js'; import { runPrint, type OutFn } from './print.js'; -import { errorMessage, selectPrinter, SelectionError, type PrinterSelector } from './select.js'; +import { selectPrinter, SelectionError, type PrinterSelector } from './select.js'; export type { OutFn } from './print.js'; @@ -22,37 +22,32 @@ export async function printImageCommand( ): Promise { const out = options.out ?? defaultOut; - let selection; - try { - selection = await selectPrinter(options, options.importer); - } catch (err) { - if (err instanceof SelectionError) { - out(chalk.red(err.message)); - process.exitCode = 1; - return; - } - throw err; - } - + // Load before opening so a bad file never touches the printer. let image; try { image = await renderImageLabel(path, options, options.readFileFn); } catch (err) { - out(chalk.red(`Failed to load image: ${errorMessage(err)}`)); + out(chalk.red(`Failed to load image: ${err instanceof Error ? err.message : String(err)}`)); process.exitCode = 1; return; } - let printer; + let selection; try { - printer = await selection.driver.discovery.openPrinter(selection.openOptions); + selection = await selectPrinter( + { invocation: `print image ${path}`, ...options }, + options.importer, + ); } catch (err) { - out(chalk.red(`Failed to open printer: ${errorMessage(err)}`)); - process.exitCode = 1; - return; + if (err instanceof SelectionError) { + out(chalk.red(err.message)); + process.exitCode = 1; + return; + } + throw err; } - await runPrint(out, selection.driver, printer, image, options); + await runPrint(out, selection.driver, selection.printer, image, options); } function defaultOut(line: string): void { diff --git a/src/commands/print-text.ts b/src/commands/print-text.ts index 0df9390..bc69e46 100644 --- a/src/commands/print-text.ts +++ b/src/commands/print-text.ts @@ -4,7 +4,7 @@ import type { DynamicImporter } from '../discovery.js'; import { renderTextLabel, type TextOptions } from '../render.js'; import { runPrint, type OutFn } from './print.js'; -import { errorMessage, selectPrinter, SelectionError, type PrinterSelector } from './select.js'; +import { selectPrinter, SelectionError, type PrinterSelector } from './select.js'; export type { OutFn } from './print.js'; @@ -21,9 +21,15 @@ export async function printTextCommand( ): Promise { const out = options.out ?? defaultOut; + // Render before opening so a bad label never touches the printer. + const image = renderTextLabel(text, options); + let selection; try { - selection = await selectPrinter(options, options.importer); + selection = await selectPrinter( + { invocation: `print text ${JSON.stringify(text)}`, ...options }, + options.importer, + ); } catch (err) { if (err instanceof SelectionError) { out(chalk.red(err.message)); @@ -33,18 +39,7 @@ export async function printTextCommand( throw err; } - const image = renderTextLabel(text, options); - - let printer; - try { - printer = await selection.driver.discovery.openPrinter(selection.openOptions); - } catch (err) { - out(chalk.red(`Failed to open printer: ${errorMessage(err)}`)); - process.exitCode = 1; - return; - } - - await runPrint(out, selection.driver, printer, image, options); + await runPrint(out, selection.driver, selection.printer, image, options); } function defaultOut(line: string): void { diff --git a/src/commands/select.ts b/src/commands/select.ts index 67010ca..2b6cc6f 100644 --- a/src/commands/select.ts +++ b/src/commands/select.ts @@ -1,4 +1,10 @@ -import type { DiscoveredPrinter, MediaDescriptor, OpenOptions } from '@thermal-label/contracts'; +import type { + DiscoveredPrinter, + MediaDescriptor, + OpenOptions, + PrinterAdapter, +} from '@thermal-label/contracts'; +import { DeviceIdentificationRequiredError } from '@thermal-label/contracts'; import { discoverAll, @@ -19,10 +25,18 @@ export interface PrinterSelector { media?: string; /** `--community`: SNMP community, forwarded as `OpenOptions.snmpCommunity`. */ community?: string; + /** + * The command as the user typed it, without selection flags + * (`status`, `print text "hi"`). Used to render a copy-pasteable + * invocation when a driver asks for `--device`. + */ + invocation?: string; } export interface SelectionResult { driver: LoadedDriver; + /** Already opened; the caller owns `close()`. */ + printer: PrinterAdapter; openOptions: OpenOptions; } @@ -33,16 +47,18 @@ export class SelectionError extends Error { } } +const NO_PRINTERS_HINT = + 'No printers found. Make sure your printer is connected via USB or accessible via TCP.'; + +interface Decline { + family: string; + error: unknown; +} + export async function selectPrinter( selector: PrinterSelector, importer?: DynamicImporter, ): Promise { - if (selector.host !== undefined && selector.printer === undefined) { - throw new SelectionError( - 'Specify --printer when using --host; the CLI cannot infer which driver speaks to an arbitrary IP.', - ); - } - const drivers = await loadDrivers(KNOWN_DRIVERS, importer); if (drivers.length === 0) { throw new SelectionError( @@ -62,14 +78,7 @@ export async function selectPrinter( } if (selector.host !== undefined) { - const opts: OpenOptions = { host: selector.host }; - if (selector.port !== undefined) opts.port = selector.port; - if (selector.serial !== undefined) opts.serialNumber = selector.serial; - if (selector.device !== undefined) opts.deviceKey = selector.device; - if (selector.community !== undefined) opts.snmpCommunity = selector.community; - const [driver] = filtered; - if (!driver) throw new SelectionError('Internal: no driver after filter.'); - return { driver, openOptions: opts }; + return openByHost({ ...selector, host: selector.host }, filtered); } const discovered = await discoverAll(filtered.map(d => d.discovery)); @@ -81,7 +90,7 @@ export async function selectPrinter( if (matched.length === 0) { throw new SelectionError( selector.serial === undefined - ? 'No printers found. Make sure your printer is connected via USB or accessible via TCP.' + ? NO_PRINTERS_HINT : `No printer found with serial number '${selector.serial}'.`, ); } @@ -101,9 +110,56 @@ export async function selectPrinter( const opts: OpenOptions = {}; if (picked.serialNumber !== undefined) opts.serialNumber = picked.serialNumber; + if (picked.host !== undefined) { + // Network result: re-open by address with the key discovery already + // resolved, so the driver does not identify a second time. + opts.host = picked.host; + if (picked.port !== undefined) opts.port = picked.port; + opts.deviceKey = selector.device ?? picked.device.key; + } else if (selector.device !== undefined) { + opts.deviceKey = selector.device; + } + if (selector.community !== undefined) opts.snmpCommunity = selector.community; + + const outcome = await tryOpen(driver, opts); + if (outcome.ok) return { driver, printer: outcome.printer, openOptions: opts }; + throw new SelectionError(formatDeclines([outcome.decline], selector, false)); +} + +async function openByHost( + selector: PrinterSelector & { host: string }, + drivers: readonly LoadedDriver[], +): Promise { + const opts: OpenOptions = { host: selector.host }; + if (selector.port !== undefined) opts.port = selector.port; + if (selector.serial !== undefined) opts.serialNumber = selector.serial; if (selector.device !== undefined) opts.deviceKey = selector.device; if (selector.community !== undefined) opts.snmpCommunity = selector.community; - return { driver, openOptions: opts }; + + // Sequential on purpose: at most one driver holds a 9100 socket, and a + // driver that throws before connecting never opens one. Every failure, + // typed or not, is a decline; the walk only fails when all drivers do. + const declines: Decline[] = []; + for (const driver of drivers) { + const outcome = await tryOpen(driver, opts); + if (outcome.ok) return { driver, printer: outcome.printer, openOptions: opts }; + declines.push(outcome.decline); + } + throw new SelectionError(formatDeclines(declines, selector, selector.printer === undefined)); +} + +type OpenOutcome = { ok: true; printer: PrinterAdapter } | { ok: false; decline: Decline }; + +async function tryOpen(driver: LoadedDriver, opts: OpenOptions): Promise { + const family = driver.discovery.family; + try { + // `await` inside the try so a synchronous throw and a rejection land + // in the same catch. + const printer = await driver.discovery.openPrinter(opts); + return { ok: true, printer }; + } catch (error: unknown) { + return { ok: false, decline: { family, error } }; + } } /** @@ -144,6 +200,57 @@ function formatMultiple(printers: readonly DiscoveredPrinter[]): string { return lines.join('\n'); } +function formatDeclines( + declines: readonly Decline[], + selector: PrinterSelector, + walked: boolean, +): string { + const lines = [ + walked + ? `No installed driver could open ${selector.host ?? ''}:` + : `Failed to open printer${selector.host === undefined ? '' : ` at ${selector.host}`}:`, + ]; + for (const { family, error } of declines) { + lines.push(` ${family}: ${errorMessage(error)}`); + if (error instanceof DeviceIdentificationRequiredError) { + lines.push(...formatIdentificationHint(family, error, selector)); + } + } + if (walked) { + lines.push( + '', + "Pass --printer to see one driver's error, or --device to name the model.", + ); + } + return lines.join('\n'); +} + +function formatIdentificationHint( + family: string, + error: DeviceIdentificationRequiredError, + selector: PrinterSelector, +): string[] { + const lines: string[] = []; + const keyWidth = Math.max(...error.candidates.map(c => c.key.length)); + lines.push(' candidates (key name):'); + for (const c of error.candidates) { + lines.push(` ${c.key.padEnd(keyWidth)} ${c.name}`); + } + const [first] = error.candidates; + if (first === undefined) return lines; + + const parts = ['thermal-label', selector.invocation ?? '']; + if (selector.host !== undefined) parts.push(`--host ${selector.host}`); + if (selector.port !== undefined) parts.push(`--port ${selector.port.toString()}`); + parts.push(`--printer ${family}`, `--device ${first.key}`); + if (STATUS_UNAVAILABLE.test(error.message)) parts.push('--media '); + lines.push(` copy, swapping the key for your model:`, ` ${parts.join(' ')}`); + return lines; +} + +/** Driver messages that say status cannot be read either (no SNMP answer). */ +const STATUS_UNAVAILABLE = /no SNMP answer|status is unavailable/i; + export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/commands/status.ts b/src/commands/status.ts index e09a69f..7ea83ed 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -18,7 +18,7 @@ export async function statusCommand(options: StatusCommandOptions = {}): Promise let selection; try { - selection = await selectPrinter(options, options.importer); + selection = await selectPrinter({ invocation: 'status', ...options }, options.importer); } catch (err) { if (err instanceof SelectionError) { out(chalk.red(err.message)); @@ -28,15 +28,7 @@ export async function statusCommand(options: StatusCommandOptions = {}): Promise throw err; } - let printer: PrinterAdapter; - try { - printer = await selection.driver.discovery.openPrinter(selection.openOptions); - } catch (err) { - out(chalk.red(`Failed to open printer: ${err instanceof Error ? err.message : String(err)}`)); - process.exitCode = 1; - return; - } - + const { printer } = selection; try { const explicitMedia = options.media === undefined ? undefined : resolveMedia(selection.driver, options.media); From 115a705f3a9abfd7a5a9b06f9d291ffcbc01014a Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 00:17:41 +0200 Subject: [PATCH 05/13] feat(list): network rows Discovered network printers render as host:port from DiscoveredPrinter host/port (connectionId stays opaque) with a Serial column for every row. The no-printers hint, shared with selection, now says what the scan is (SNMP broadcast) and when to pass --host instead. --- src/__tests__/list.test.ts | 46 +++++++++++++++++++++++++++++++++++- src/__tests__/select.test.ts | 6 +++++ src/commands/list.ts | 12 ++++++---- src/commands/select.ts | 4 ++-- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/__tests__/list.test.ts b/src/__tests__/list.test.ts index 81e1441..664fdfc 100644 --- a/src/__tests__/list.test.ts +++ b/src/__tests__/list.test.ts @@ -125,10 +125,54 @@ describe('list command', () => { : Promise.reject(new Error('missing')), ); await listCommand({ importer, out }); - expect(lines[0]).toBe('No printers found.'); + expect(lines[0]).toMatch(/^No printers found\. USB: .* Network: .*SNMP broadcast.*--host /); expect(lines.some(l => l.includes('brother-ql'))).toBe(true); }); + it('renders network rows as host:port with the serial column', async () => { + const { out, lines } = collectOutput(); + const discovery: PrinterDiscovery = { + family: 'brother-ql', + listPrinters: (): Promise => + Promise.resolve([ + { + device: mockDevice({ + family: 'brother-ql', + name: 'QL-820NWBc', + transport: 'tcp', + connectionId: 'opaque', + }), + transport: 'tcp', + connectionId: 'opaque', + host: '192.168.1.67', + port: 9100, + serialNumber: 'M5G679125', + }, + { + device: mockDevice({ + family: 'brother-ql', + name: 'QL-800', + transport: 'usb', + connectionId: '3.10', + }), + transport: 'usb', + connectionId: '3.10', + }, + ]), + openPrinter: () => Promise.reject(new Error('not used')), + }; + const importer = vi.fn((pkg: string) => + pkg === '@thermal-label/brother-ql-node' + ? Promise.resolve({ discovery }) + : Promise.reject(new Error('missing')), + ); + await listCommand({ importer, out }); + expect(lines[0]).toMatch(/Family\s+Model\s+Transport\s+Connection\s+Serial/); + expect(lines[1]).toMatch(/brother-ql\s+QL-820NWBc\s+tcp\s+192\.168\.1\.67:9100\s+M5G679125/); + expect(lines[2]).toMatch(/brother-ql\s+QL-800\s+usb\s+3\.10\s*$/); + expect(lines.join('\n')).not.toContain('opaque'); + }); + it('--drivers flag shows package install status with all three known drivers', async () => { const { out, lines } = collectOutput(); const discovery = mockDiscovery('brother-ql', []); diff --git a/src/__tests__/select.test.ts b/src/__tests__/select.test.ts index ddeed3f..7f4d9a4 100644 --- a/src/__tests__/select.test.ts +++ b/src/__tests__/select.test.ts @@ -279,6 +279,12 @@ describe('selectPrinter via discovery', () => { expect(err.message).toContain('labelwriter: USB permission denied'); expect(err.message).not.toContain('--printer '); }); + + it('uses the network hint when nothing is found', async () => { + const brotherQl = fakeDriver('brother-ql', () => adapter('brother-ql', 'x')); + const err = await failure(selectPrinter({}, importerFor({ 'brother-ql': brotherQl }))); + expect(err.message).toMatch(/^No printers found\. USB: .*SNMP broadcast.*--host /); + }); }); describe('resolveMedia', () => { diff --git a/src/commands/list.ts b/src/commands/list.ts index 42ac6f7..28d616c 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -11,6 +11,8 @@ import { type DynamicImporter, } from '../discovery.js'; +import { NO_PRINTERS_HINT } from './select.js'; + export type OutFn = (line: string) => void; export interface ListCommandOptions { @@ -53,10 +55,13 @@ export async function listCommand(options: ListCommandOptions = {}): Promise Math.max(...rows.map(r => (r[col] ?? '').length))); for (const [i, row] of rows.entries()) { @@ -105,10 +110,9 @@ function printNoDriversInstalled(out: OutFn): void { } function printNoPrintersFound(out: OutFn, families: readonly string[]): void { - out('No printers found.'); + out(NO_PRINTERS_HINT); out(''); out(`Installed drivers: ${families.join(', ')}`); - out('Make sure your printer is connected via USB or accessible via TCP.'); } function defaultOut(line: string): void { diff --git a/src/commands/select.ts b/src/commands/select.ts index 2b6cc6f..f12386c 100644 --- a/src/commands/select.ts +++ b/src/commands/select.ts @@ -47,8 +47,8 @@ export class SelectionError extends Error { } } -const NO_PRINTERS_HINT = - 'No printers found. Make sure your printer is connected via USB or accessible via TCP.'; +export const NO_PRINTERS_HINT = + 'No printers found. USB: check the cable and permissions. Network: the scan uses SNMP broadcast; on another subnet or with SNMP disabled pass --host .'; interface Decline { family: string; From bcc51e623a5090bccb51b389f6ceae0fae366c6d Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 00:17:54 +0200 Subject: [PATCH 06/13] fix(print): surface getStatus failures The pre-print status query used to be swallowed. Now a failure is fatal unless --media was given (then it is a warning and the job goes out sized by --media), and the status error rows plus warn/error details rows are echoed as warnings before printing, so the two-colour caveat on a network Brother is visible at print time, not only in status. --- src/__tests__/print-text.test.ts | 58 ++++++++++++++++++++++++++++++++ src/commands/print.ts | 26 +++++++++++--- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/__tests__/print-text.test.ts b/src/__tests__/print-text.test.ts index 8ff21aa..bc5682e 100644 --- a/src/__tests__/print-text.test.ts +++ b/src/__tests__/print-text.test.ts @@ -306,4 +306,62 @@ describe('print text: --media and status failures', () => { expect(lines.some(l => l.includes('does not expose a media catalog'))).toBe(true); expect(process.exitCode).toBe(1); }); + + it('exits with the driver message when getStatus fails and no --media was given', async () => { + const mock = mockAdapter(stdMedia); + mock.adapter.getStatus = () => Promise.reject(new Error('no SNMP answer from 10.0.0.9')); + const discovery = discoveryWith(mock.adapter, () => [stdMedia]); + const lines: string[] = []; + await printTextCommand('X', { importer: importerFor(discovery), out: s => lines.push(s) }); + expect(lines.some(l => l.includes('Status query failed: no SNMP answer from 10.0.0.9'))).toBe( + true, + ); + expect(lines.some(l => l.includes('--media '))).toBe(true); + expect(mock.printCalls).toHaveLength(0); + expect(mock.closeCalls).toBe(1); + expect(process.exitCode).toBe(1); + }); + + it('warns and prints when getStatus fails but --media was given', async () => { + const mock = mockAdapter(stdMedia); + mock.adapter.getStatus = () => Promise.reject(new Error('no SNMP answer from 10.0.0.9')); + const discovery = discoveryWith(mock.adapter, () => [stdMedia]); + const lines: string[] = []; + await printTextCommand('X', { + importer: importerFor(discovery), + out: s => lines.push(s), + media: '259', + }); + expect(lines.some(l => l.includes('Warning: status query failed (no SNMP answer'))).toBe(true); + expect(mock.printCalls).toHaveLength(1); + expect(lines.some(l => l.includes('Printed 1 label'))).toBe(true); + expect(process.exitCode).toBe(0); + }); + + it('surfaces status error rows and warn details as warnings and still prints', async () => { + const mock = mockAdapter(stdMedia); + mock.adapter.getStatus = (): Promise => + Promise.resolve({ + ready: true, + mediaLoaded: true, + detectedMedia: stdMedia, + errors: [{ code: 'low_media', message: 'Roll nearly out' }], + rawBytes: new Uint8Array(), + details: [ + { label: 'Printer state', value: 'idle' }, + { label: 'Two-colour', value: 'not detectable over network', severity: 'warn' }, + ], + }); + const discovery = discoveryWith(mock.adapter); + const lines: string[] = []; + await printTextCommand('X', { importer: importerFor(discovery), out: s => lines.push(s) }); + expect( + lines.some(l => l.includes('Warning: printer reports [low_media] Roll nearly out')), + ).toBe(true); + expect(lines.some(l => l.includes('Warning: Two-colour: not detectable over network'))).toBe( + true, + ); + expect(lines.some(l => l.includes('Printer state'))).toBe(false); + expect(mock.printCalls).toHaveLength(1); + }); }); diff --git a/src/commands/print.ts b/src/commands/print.ts index 905faa5..6b357b1 100644 --- a/src/commands/print.ts +++ b/src/commands/print.ts @@ -38,12 +38,28 @@ export async function runPrint( let media: MediaDescriptor | undefined; if (options.media !== undefined) media = resolveMedia(driver, options.media); - // Warm the adapter's media cache so print() can default to detected media. + // The status query warms the driver's media cache so print() can + // default to detected media, and surfaces error rows. Without + // --media a failure here means the job cannot be sized, so stop. try { - await printer.getStatus(); - } catch { - // Ignore — with --media the job is sized anyway; without it the - // driver raises MediaNotSpecifiedError below. + const status = await printer.getStatus(); + for (const e of status.errors) { + out(chalk.yellow(`Warning: printer reports [${e.code}] ${e.message}`)); + } + for (const d of status.details ?? []) { + if (d.severity === 'warn' || d.severity === 'error') { + out(chalk.yellow(`Warning: ${d.label}: ${d.value}`)); + } + } + } catch (err) { + const message = errorMessage(err); + if (media === undefined) { + out(chalk.red(`Status query failed: ${message}`)); + out(chalk.red('Pass --media to print without media detection.')); + process.exitCode = 1; + return; + } + out(chalk.yellow(`Warning: status query failed (${message}); printing with --media.`)); } const copies = options.copies ?? 1; From c1cdbc4a527ef665d8245e6f100c0c49997d3625 Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 00:32:51 +0200 Subject: [PATCH 07/13] fix(print): send blind when status is unavailable --- src/__tests__/print-text.test.ts | 16 ++++++++++++++++ src/commands/print.ts | 11 +++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/__tests__/print-text.test.ts b/src/__tests__/print-text.test.ts index bc5682e..7e64cf4 100644 --- a/src/__tests__/print-text.test.ts +++ b/src/__tests__/print-text.test.ts @@ -333,11 +333,27 @@ describe('print text: --media and status failures', () => { media: '259', }); expect(lines.some(l => l.includes('Warning: status query failed (no SNMP answer'))).toBe(true); + expect(lines.some(l => l.includes('without print confirmation'))).toBe(true); expect(mock.printCalls).toHaveLength(1); + expect(mock.printCalls[0]?.options).toEqual({ confirm: false }); expect(lines.some(l => l.includes('Printed 1 label'))).toBe(true); expect(process.exitCode).toBe(0); }); + it('does not touch confirm when the status query succeeds', async () => { + const mock = mockAdapter(stdMedia); + const discovery = discoveryWith(mock.adapter, () => [stdMedia]); + const lines: string[] = []; + await printTextCommand('X', { + importer: importerFor(discovery), + out: s => lines.push(s), + media: '259', + }); + expect(mock.printCalls).toHaveLength(1); + expect(mock.printCalls[0]?.options).not.toHaveProperty('confirm'); + expect(process.exitCode).toBe(0); + }); + it('surfaces status error rows and warn details as warnings and still prints', async () => { const mock = mockAdapter(stdMedia); mock.adapter.getStatus = (): Promise => diff --git a/src/commands/print.ts b/src/commands/print.ts index 6b357b1..ac8b6b2 100644 --- a/src/commands/print.ts +++ b/src/commands/print.ts @@ -41,6 +41,9 @@ export async function runPrint( // The status query warms the driver's media cache so print() can // default to detected media, and surfaces error rows. Without // --media a failure here means the job cannot be sized, so stop. + // With --media the job still goes out, but blind: the channel a + // driver would confirm the print on (SNMP) is the one that failed. + const printOpts: PrintOptions = {}; try { const status = await printer.getStatus(); for (const e of status.errors) { @@ -59,11 +62,15 @@ export async function runPrint( process.exitCode = 1; return; } - out(chalk.yellow(`Warning: status query failed (${message}); printing with --media.`)); + out( + chalk.yellow( + `Warning: status query failed (${message}); printing with --media, without print confirmation.`, + ), + ); + printOpts.confirm = false; } const copies = options.copies ?? 1; - const printOpts: PrintOptions = {}; if (options.density !== undefined) printOpts.density = options.density; for (let i = 0; i < copies; i++) { await printer.print(image, media, printOpts); From cce4c936243c88db6bda7464109f471cfc42009d Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 01:35:50 +0200 Subject: [PATCH 08/13] feat(print): --no-confirm Sends blind on demand: the automatic confirm:false only fires when the status query failed and --media is given, which leaves no way out when status works but the driver's confirmation channel (SNMP page counter) does not. --- src/__tests__/print-text.test.ts | 29 +++++++++++++++++++++++++++++ src/commands/print-image.ts | 2 ++ src/commands/print-text.ts | 2 ++ src/commands/print.ts | 9 +++++---- src/index.ts | 13 +++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/__tests__/print-text.test.ts b/src/__tests__/print-text.test.ts index 7e64cf4..85bece7 100644 --- a/src/__tests__/print-text.test.ts +++ b/src/__tests__/print-text.test.ts @@ -197,6 +197,20 @@ describe('print text command', () => { expect(process.exitCode).toBe(1); }); + it('recognises a MediaNotSpecifiedError from another contracts copy by name', async () => { + const foreign = new Error('no media'); + foreign.name = 'MediaNotSpecifiedError'; + const mock = mockAdapter(undefined, foreign); + const importer = (pkg: string) => + pkg === '@thermal-label/brother-ql-node' + ? Promise.resolve({ discovery: mockDiscovery(mock) }) + : Promise.reject(new Error('missing')); + const lines: string[] = []; + await printTextCommand('X', { importer, out: s => lines.push(s) }); + expect(lines.some(l => l.includes('No media'))).toBe(true); + expect(process.exitCode).toBe(1); + }); + it('errors cleanly when no printer is found', async () => { const importer = (pkg: string) => { void pkg; @@ -340,6 +354,21 @@ describe('print text: --media and status failures', () => { expect(process.exitCode).toBe(0); }); + it('--no-confirm sends blind even when the status query succeeds', async () => { + const mock = mockAdapter(stdMedia); + const discovery = discoveryWith(mock.adapter, () => [stdMedia]); + const lines: string[] = []; + await printTextCommand('X', { + importer: importerFor(discovery), + out: s => lines.push(s), + confirm: false, + }); + expect(lines.some(l => l.includes('Printed 1 label'))).toBe(true); + expect(mock.printCalls).toHaveLength(1); + expect(mock.printCalls[0]?.options).toEqual({ confirm: false }); + expect(process.exitCode).toBe(0); + }); + it('does not touch confirm when the status query succeeds', async () => { const mock = mockAdapter(stdMedia); const discovery = discoveryWith(mock.adapter, () => [stdMedia]); diff --git a/src/commands/print-image.ts b/src/commands/print-image.ts index dffe7b9..c1a14b6 100644 --- a/src/commands/print-image.ts +++ b/src/commands/print-image.ts @@ -11,6 +11,8 @@ export type { OutFn } from './print.js'; export interface PrintImageCommandOptions extends PrinterSelector, ImageOptions { density?: string; copies?: number; + /** `--no-confirm`: skip the driver's out-of-band print confirmation. */ + confirm?: false; importer?: DynamicImporter; readFileFn?: ReadFileFn; out?: OutFn; diff --git a/src/commands/print-text.ts b/src/commands/print-text.ts index bc69e46..c9bb9d5 100644 --- a/src/commands/print-text.ts +++ b/src/commands/print-text.ts @@ -11,6 +11,8 @@ export type { OutFn } from './print.js'; export interface PrintTextCommandOptions extends PrinterSelector, TextOptions { density?: string; copies?: number; + /** `--no-confirm`: skip the driver's out-of-band print confirmation. */ + confirm?: false; importer?: DynamicImporter; out?: OutFn; } diff --git a/src/commands/print.ts b/src/commands/print.ts index ac8b6b2..abf5bad 100644 --- a/src/commands/print.ts +++ b/src/commands/print.ts @@ -1,7 +1,5 @@ import chalk from 'chalk'; -import { MediaNotSpecifiedError } from '@thermal-label/contracts'; - import type { MediaDescriptor, PrinterAdapter, @@ -11,7 +9,7 @@ import type { import type { LoadedDriver } from '../discovery.js'; -import { errorMessage, resolveMedia, SelectionError } from './select.js'; +import { errorMessage, isContractsError, resolveMedia, SelectionError } from './select.js'; export type OutFn = (line: string) => void; @@ -20,6 +18,8 @@ export interface PrintRunOptions { copies?: number; /** `--media`: overrides detected media; also lets a failed status query through. */ media?: string; + /** `--no-confirm`: send blind even when the status query succeeded. */ + confirm?: false; } /** @@ -44,6 +44,7 @@ export async function runPrint( // With --media the job still goes out, but blind: the channel a // driver would confirm the print on (SNMP) is the one that failed. const printOpts: PrintOptions = {}; + if (options.confirm === false) printOpts.confirm = false; try { const status = await printer.getStatus(); for (const e of status.errors) { @@ -82,7 +83,7 @@ export async function runPrint( process.exitCode = 1; return; } - if (err instanceof MediaNotSpecifiedError) { + if (isContractsError(err, 'MediaNotSpecifiedError')) { out( chalk.red( 'No media is loaded or detected. Load media, pass --media , or use a driver with media auto-detection (e.g. Brother QL).', diff --git a/src/index.ts b/src/index.ts index 71f9eda..2a564e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,8 @@ interface CommanderPrintTextOpts extends CommanderStatusOpts { scaleY?: number; density?: string; copies?: number; + /** commander negatable `--no-confirm`: `true` unless the flag is given. */ + confirm?: boolean; } interface CommanderPrintImageOpts extends CommanderStatusOpts { @@ -48,6 +50,7 @@ interface CommanderPrintImageOpts extends CommanderStatusOpts { rotate?: 0 | 90 | 180 | 270; density?: string; copies?: number; + confirm?: boolean; } function buildStatusOptions(opts: CommanderStatusOpts): StatusCommandOptions { @@ -69,6 +72,7 @@ function buildPrintTextOptions(opts: CommanderPrintTextOpts): PrintTextCommandOp if (opts.scaleY !== undefined) out.scaleY = opts.scaleY; if (opts.density !== undefined) out.density = opts.density; if (opts.copies !== undefined) out.copies = opts.copies; + if (opts.confirm === false) out.confirm = false; return out; } @@ -80,6 +84,7 @@ function buildPrintImageOptions(opts: CommanderPrintImageOpts): PrintImageComman if (opts.rotate !== undefined) out.rotate = opts.rotate; if (opts.density !== undefined) out.density = opts.density; if (opts.copies !== undefined) out.copies = opts.copies; + if (opts.confirm === false) out.confirm = false; return out; } @@ -129,6 +134,10 @@ export function buildProgram(): Command { .option('--scale-y ', 'Vertical scale factor', parseIntArg) .option('--density ', 'Driver-specific density (light, normal, dark)') .option('--copies ', 'Number of copies', parseIntArg) + .option( + '--no-confirm', + 'Send without out-of-band print confirmation (network printers whose SNMP page counter cannot be read)', + ) .action(async (text: string, opts: CommanderPrintTextOpts) => { await printTextCommand(text, buildPrintTextOptions(opts)); }); @@ -140,6 +149,10 @@ export function buildProgram(): Command { .option('--rotate ', 'Rotation in degrees (0, 90, 180, 270)', parseRotateArg) .option('--density ', 'Driver-specific density (light, normal, dark)') .option('--copies ', 'Number of copies', parseIntArg) + .option( + '--no-confirm', + 'Send without out-of-band print confirmation (network printers whose SNMP page counter cannot be read)', + ) .action(async (file: string, opts: CommanderPrintImageOpts) => { await printImageCommand(file, buildPrintImageOptions(opts)); }); From 2811872d2c9f7306f8f00bd1f63b0c023e385d83 Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 01:35:50 +0200 Subject: [PATCH 09/13] fix: match contracts errors by name as well as instanceof instanceof only holds when the CLI and the driver load one @thermal-label/contracts module; a nested or hoisted second copy downgraded DeviceIdentificationRequiredError to its bare message and MediaNotSpecifiedError to the generic failure. --- src/__tests__/select.test.ts | 18 ++++++++++++++++++ src/commands/select.ts | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/__tests__/select.test.ts b/src/__tests__/select.test.ts index 7f4d9a4..c9b4733 100644 --- a/src/__tests__/select.test.ts +++ b/src/__tests__/select.test.ts @@ -155,6 +155,24 @@ describe('selectPrinter with --host', () => { expect(msg).toContain('--printer '); }); + it('renders candidates for an identification error from another contracts copy (name match)', async () => { + const foreign = Object.assign(new Error('no SNMP answer from 10.0.0.9'), { + name: 'DeviceIdentificationRequiredError', + candidates: [QL], + continueWith: () => Promise.reject(new Error('not used')), + }); + const brotherQl = fakeDriver('brother-ql', () => { + throw foreign; + }); + const importer = importerFor({ 'brother-ql': brotherQl }); + + const err = await failure(selectPrinter({ host: '10.0.0.9', invocation: 'status' }, importer)); + expect(err.message).toMatch(/QL_820NWBc\s+QL-820NWBc/); + expect(err.message).toContain( + 'thermal-label status --host 10.0.0.9 --printer brother-ql --device QL_820NWBc --media ', + ); + }); + it('omits --media from the copy line when the driver identified the model as unknown', async () => { const idErr = new DeviceIdentificationRequiredError([QL], () => Promise.reject(new Error('not used')), diff --git a/src/commands/select.ts b/src/commands/select.ts index f12386c..238a2b2 100644 --- a/src/commands/select.ts +++ b/src/commands/select.ts @@ -4,7 +4,10 @@ import type { OpenOptions, PrinterAdapter, } from '@thermal-label/contracts'; -import { DeviceIdentificationRequiredError } from '@thermal-label/contracts'; +import { + DeviceIdentificationRequiredError, + MediaNotSpecifiedError, +} from '@thermal-label/contracts'; import { discoverAll, @@ -212,7 +215,7 @@ function formatDeclines( ]; for (const { family, error } of declines) { lines.push(` ${family}: ${errorMessage(error)}`); - if (error instanceof DeviceIdentificationRequiredError) { + if (isContractsError(error, 'DeviceIdentificationRequiredError')) { lines.push(...formatIdentificationHint(family, error, selector)); } } @@ -254,3 +257,28 @@ const STATUS_UNAVAILABLE = /no SNMP answer|status is unavailable/i; export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +type ContractsErrorName = 'DeviceIdentificationRequiredError' | 'MediaNotSpecifiedError'; +type ContractsErrorOf = N extends 'DeviceIdentificationRequiredError' + ? DeviceIdentificationRequiredError + : MediaNotSpecifiedError; + +/** + * `instanceof` only holds when the CLI and the driver load the same + * `@thermal-label/contracts` module; with npm hoisting quirks or a + * nested copy they do not. The contracts classes all set `name`, so + * match on that as well. + */ +export function isContractsError( + error: unknown, + name: N, +): error is ContractsErrorOf { + if (!(error instanceof Error)) return false; + if (name === 'DeviceIdentificationRequiredError') { + return ( + error instanceof DeviceIdentificationRequiredError || + (error.name === name && Array.isArray((error as { candidates?: unknown }).candidates)) + ); + } + return error instanceof MediaNotSpecifiedError || error.name === name; +} From 589ca7f9f2a53cd2536e925a8dad375f3ea11bf5 Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 00:19:14 +0200 Subject: [PATCH 10/13] release: 0.6.0 Network printers: --device / --media / --community, --host walks the installed drivers, network rows in list, status failures surfaced at print time. Docs and README updated. The contracts ^0.6.2 and brother-ql-node ^0.6.2 pins follow once both are published. --- DECISIONS.md | 33 +++++++++++++++++++++ PROGRESS.md | 14 +++++++++ README.md | 9 +++++- docs/index.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++----- package.json | 2 +- src/index.ts | 2 +- 6 files changed, 130 insertions(+), 10 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 880f270..18d2a89 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -76,3 +76,36 @@ and dispatch to the corresponding decoder. gives a single `loadImageFile(path) → RawImageData` surface for the print-image command without spreading format knowledge across the codebase. + +## D6 — `--host` walks the drivers; every failure is a decline + +**Plan said (plan 17 D7):** walk installed drivers on `--host` without +`--printer`; typed "not mine" errors mean move on, anything else is that +driver's error. +**Chose:** every rejection or synchronous throw from `openPrinter` is a +decline, whatever its type. The walk only fails when all drivers decline, +and then prints each driver's reason. + +**Why:** the published labelwriter-node throws a plain `Error` when +`deviceKey` is missing on TCP, and future drivers may throw anything. A +walk that rethrows untyped errors would make a Brother print die on an +unrelated installed driver. The reasons are not lost: they are all shown +when nobody succeeds, and `--printer ` still surfaces one +driver's error verbatim. Sequential, not concurrent, so at most one +driver holds a 9100 socket; the cost today is one SNMP timeout (only +brother-ql identifies over the network). + +`selectPrinter` now opens the printer itself so there is one open site +for the walk, the `--printer` path and the discovered path. Discovered +network printers are re-opened with the discovered device key so the +driver does not identify twice. + +## D7 — `list` shows a Serial column instead of a `serial=` suffix + +**Plan said (plan 17 D7):** render network rows as +`brother-ql QL-820NWBc tcp 192.168.1.67:9100 serial=M5G679125`. +**Chose:** a fifth `Serial` column on every row, blank when unknown. + +**Why:** USB rows carry serials too, and a column keeps the table +aligned; the information is the same. The "multiple printers found" +message keeps the `serial=` suffix, it is a sentence not a table. diff --git a/PROGRESS.md b/PROGRESS.md index 396af94..7a7e90a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -86,3 +86,17 @@ Tracks completion of the steps in `PLAN.md` §10. - [x] Test fixtures drop `colorCapable: false` (`status.test.ts`, `print-text.test.ts`, `print-image.test.ts`) - [x] Gates green (typecheck, lint, format, test, build) +## Step — Network printers (plan 17 step 5, 0.6.0) + +> Plan: `~/thermal-label/plans/backlog/17-network-discovery-snmp.md` D7 + +- [x] `--device`, `--media`, `--community` on `status`, `print text`, `print image` +- [x] `--host` without `--printer` walks drivers; every failure declines; `DeviceIdentificationRequiredError` rendered with candidates and a copy line +- [x] `selectPrinter` opens; discovered network printers re-open with `deviceKey` +- [x] `--media` resolved through `listMedia()`; `print` stops swallowing `getStatus()` failures +- [x] `list`: host:port rows, Serial column, SNMP-broadcast hint +- [x] Tests: `select.test.ts` (walk, re-open, media), status/list/print-text suites extended — 87 tests, coverage 95/89/85/95 +- [x] Docs: `docs/index.md` network section + flags, README +- [x] Version 0.6.0 +- [ ] Pin `@thermal-label/contracts ^0.6.2` and `@thermal-label/brother-ql-node ^0.6.2` + lockfile — after both are on npm +- [ ] Bench B7–B12 (maintainer, QL-820NWBc at 192.168.1.67) diff --git a/README.md b/README.md index 3b1bf2a..9a43aca 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,21 @@ thermal-label list # detect connected printers thermal-label status # readiness + media + errors thermal-label print text "Hello World" # quick text print thermal-label print image logo.png # PNG / JPEG print +thermal-label print text "hi" --host 192.168.1.67 # network printer, driver auto-picked +thermal-label print text "hi" --host 192.168.1.67 --device QL_820NWBc --media 251 # when it cannot identify itself ``` +Network printers show up in `list` when the driver can find them on the +LAN (Brother QL: SNMP broadcast); `--host` reaches the ones it cannot. +`--device ` and `--media ` take over when identification or +media detection is not possible. + ## Documentation Full docs at ****. - Command + flag reference -- TCP / WebUSB usage +- Network printers: `--host`, `--device`, `--media`, `--community` - thermal-label-cli vs burnmark-cli — when to use which ## Philosophy diff --git a/docs/index.md b/docs/index.md index 36bb1f7..2ecd09d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,11 +54,17 @@ Lists discovered printers across every installed driver. ``` $ thermal-label list -Family Model Transport Connection -brother-ql QL-820NWB usb Bus 003 Device 010 -labelwriter LabelWriter 450 usb Bus 001 Device 004 +Family Model Transport Connection Serial +brother-ql QL-820NWBc tcp 192.168.1.67:9100 M5G679125 +brother-ql QL-800 usb 3.10 +labelwriter LabelWriter 450 usb 1.4 ``` +USB rows come from the drivers' USB enumeration. Network rows come from +each driver's own LAN scan (Brother QL: one SNMP broadcast, about a +second); a printer on another subnet, or with SNMP disabled, is not +listed but still reachable with `--host`. + `--drivers` shows which known driver packages are installed: ``` @@ -84,12 +90,23 @@ Media: 62mm continuous (62mm, continuous) Errors: none ``` -Over TCP: +Over TCP (the driver is found by asking the printer what it is, see +[Network printers](#network-printers)): ``` -$ thermal-label status --printer brother-ql --host 192.168.1.42 +$ thermal-label status --host 192.168.1.67 +Printer: QL-820NWBc (brother-ql) +Status: Ready +Media: 62mm continuous (62mm, continuous) +Errors: none + Printer state: idle + Two-colour: not detectable over network +Transport: TCP 192.168.1.67:9100 ``` +Rows under `Errors:` are the driver's detail rows; warnings are shown in +yellow. + ### `print text ` Renders text to a label and prints. @@ -98,9 +115,18 @@ Renders text to a label and prints. thermal-label print text "BIN-42" thermal-label print text "FRAGILE" --invert --scale-x 2 thermal-label print text "Hello" --printer brother-ql --density dark -thermal-label print text "Label" --host 192.168.1.42 --printer brother-ql --copies 3 +thermal-label print text "Label" --host 192.168.1.67 --copies 3 +thermal-label print text "Label" --host 192.168.1.67 --device QL_820NWBc --media 251 ``` +Before printing, the CLI queries status once (drivers size the job from +the detected media and the error rows are echoed as warnings). If that +query fails the print stops, unless `--media` is given: then it warns and +prints with the media you named, sending the job blind (`confirm: false`): +a driver that would normally confirm the print over the same channel +(Brother QL over TCP checks the SNMP page counter) cannot, so "Printed" +then means "sent", not "came out". + Rendering uses [`@mbtech-nl/bitmap`](https://www.npmjs.com/package/@mbtech-nl/bitmap)'s pixel font — simple by design. For typography, barcodes, or logos, render externally and use `print image`. @@ -122,9 +148,12 @@ thermal-label print image label.png --rotate 90 --printer labelwriter | Flag | Description | |---|---| | `--printer ` | Restrict to a driver family: `brother-ql`, `labelwriter`, `labelmanager`. | -| `--host ` | Use TCP transport to the given host. Requires `--printer`. | +| `--host ` | Use TCP transport to the given host. Without `--printer`, every installed driver is asked in turn. | | `--port ` | TCP port (default `9100`). | | `--serial ` | Target a specific printer by serial number. | +| `--device ` | Registry key of the model (`QL_820NWBc`, `LW_550`, …) for drivers that cannot identify it themselves. Wins over identification. | +| `--media ` | Media id or name from the driver's catalog (`251`, `"62mm continuous"`). Overrides detected media and lets a print go out when status cannot be read. | +| `--community ` | SNMP community for network printers (default `public`). | ### `print text` @@ -147,6 +176,43 @@ thermal-label print image label.png --rotate 90 --printer labelwriter | `--density ` | `normal` | Driver-specific density. | | `--copies ` | `1` | Number of copies. | +## Network printers + +`--host ` without `--printer` walks the installed drivers in a fixed +order (brother-ql, labelwriter, labelmanager) and hands the printer to the +first one that opens it. A driver that cannot speak to that address +declines and the walk moves on, so an installed-but-unrelated driver never +breaks a print on another. Only when every driver declines does the CLI +fail, and then it prints each driver's reason: + +``` +$ thermal-label status --host 192.168.1.67 +No installed driver could open 192.168.1.67: + brother-ql: no SNMP answer from 192.168.1.67; pass deviceKey, and media, since status is unavailable too + candidates (key name): + QL_820NWBc QL-820NWBc + PT_E550W PT-E550W + … + copy, swapping the key for your model: + thermal-label status --host 192.168.1.67 --printer brother-ql --device QL_820NWBc --media + labelwriter: TCP open requires `deviceKey` — port 9100 carries no model signal, … + +Pass --printer to see one driver's error, or --device to name the model. +``` + +How a driver identifies a network printer is its own business; Brother QL +asks over SNMP (model, serial, state, loaded media) because port 9100 is +write-only. When that is not possible (SNMP disabled, printer on another +subnet with no broadcast, a model the driver does not list) `--device` +names the model and `--media` names the roll, and the job goes out +without a status read. + +Two-colour rolls (Brother DK-22251) cannot be told apart from plain +62 mm rolls over the network; the status shows a `Two-colour: not +detectable over network` warning and a job sized for the plain roll is +rejected by the printer. Pass `--media 251` on those rolls. See the +driver's troubleshooting page for the details. + ## When multiple printers are connected If more than one printer is detected and you haven't passed `--printer` or diff --git a/package.json b/package.json index 5a397d6..bc2f911 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thermal-label-cli", - "version": "0.5.0", + "version": "0.6.0", "description": "Unified CLI for thermal label printers — auto-detects all installed drivers", "keywords": [ "thermal-label", diff --git a/src/index.ts b/src/index.ts index 2a564e9..801775f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { printImageCommand, type PrintImageCommandOptions } from './commands/pri import { printTextCommand, type PrintTextCommandOptions } from './commands/print-text.js'; import { statusCommand, type StatusCommandOptions } from './commands/status.js'; -const VERSION = '0.1.0'; +const VERSION = '0.6.0'; function parseIntArg(value: string): number { const n = Number.parseInt(value, 10); From b39ea477fef94ea364df4c750a8a66aebbc981fa Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 00:51:42 +0200 Subject: [PATCH 11/13] docs: decline sample from the bench run --- docs/index.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 2ecd09d..1890c60 100644 --- a/docs/index.md +++ b/docs/index.md @@ -188,14 +188,16 @@ fail, and then it prints each driver's reason: ``` $ thermal-label status --host 192.168.1.67 No installed driver could open 192.168.1.67: - brother-ql: no SNMP answer from 192.168.1.67; pass deviceKey, and media, since status is unavailable too + brother-ql: No SNMP answer from 192.168.1.67 (Read timed out after 2000ms); the model cannot be identified and status is unavailable, so pass media too. Pass deviceKey, one of: PT_E550W, PT_P750W, …, QL_820NWBc. candidates (key name): - QL_820NWBc QL-820NWBc PT_E550W PT-E550W + PT_P750W PT-P750W … + QL_820NWBc QL-820NWBc copy, swapping the key for your model: - thermal-label status --host 192.168.1.67 --printer brother-ql --device QL_820NWBc --media - labelwriter: TCP open requires `deviceKey` — port 9100 carries no model signal, … + thermal-label status --host 192.168.1.67 --printer brother-ql --device PT_E550W --media + labelwriter: TCP open requires `deviceKey` — port 9100 carries no model signal, so the model must be declared. Available TCP-capable LabelWriter keys: LW_550_TURBO, LW_5XL, LW_WIRELESS. + labelmanager: No compatible device found Pass --printer to see one driver's error, or --device to name the model. ``` From 25c7338a9c9798e68e9361fb929a0762806ecdcf Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 01:36:00 +0200 Subject: [PATCH 12/13] docs: --no-confirm, --community scope, connection id format, test count --- PROGRESS.md | 2 +- docs/index.md | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 7a7e90a..4f5c75a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -95,7 +95,7 @@ Tracks completion of the steps in `PLAN.md` §10. - [x] `selectPrinter` opens; discovered network printers re-open with `deviceKey` - [x] `--media` resolved through `listMedia()`; `print` stops swallowing `getStatus()` failures - [x] `list`: host:port rows, Serial column, SNMP-broadcast hint -- [x] Tests: `select.test.ts` (walk, re-open, media), status/list/print-text suites extended — 87 tests, coverage 95/89/85/95 +- [x] Tests: `select.test.ts` (walk, re-open, media), status/list/print-text suites extended — 91 tests, coverage 95/89/85/95 - [x] Docs: `docs/index.md` network section + flags, README - [x] Version 0.6.0 - [ ] Pin `@thermal-label/contracts ^0.6.2` and `@thermal-label/brother-ql-node ^0.6.2` + lockfile — after both are on npm diff --git a/docs/index.md b/docs/index.md index 1890c60..5e495a3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,8 +56,8 @@ Lists discovered printers across every installed driver. $ thermal-label list Family Model Transport Connection Serial brother-ql QL-820NWBc tcp 192.168.1.67:9100 M5G679125 -brother-ql QL-800 usb 3.10 -labelwriter LabelWriter 450 usb 1.4 +brother-ql QL-800 usb 3:10 +labelwriter LabelWriter 450 usb 1:4 ``` USB rows come from the drivers' USB enumeration. Network rows come from @@ -125,7 +125,9 @@ query fails the print stops, unless `--media` is given: then it warns and prints with the media you named, sending the job blind (`confirm: false`): a driver that would normally confirm the print over the same channel (Brother QL over TCP checks the SNMP page counter) cannot, so "Printed" -then means "sent", not "came out". +then means "sent", not "came out". `--no-confirm` forces the same blind +send when the status query worked but the driver still cannot confirm +(the page counter is unreadable, or SNMP drops out between the two). Rendering uses [`@mbtech-nl/bitmap`](https://www.npmjs.com/package/@mbtech-nl/bitmap)'s pixel font — simple by design. For typography, barcodes, or logos, render @@ -153,7 +155,7 @@ thermal-label print image label.png --rotate 90 --printer labelwriter | `--serial ` | Target a specific printer by serial number. | | `--device ` | Registry key of the model (`QL_820NWBc`, `LW_550`, …) for drivers that cannot identify it themselves. Wins over identification. | | `--media ` | Media id or name from the driver's catalog (`251`, `"62mm continuous"`). Overrides detected media and lets a print go out when status cannot be read. | -| `--community ` | SNMP community for network printers (default `public`). | +| `--community ` | SNMP community for network printers opened with `--host` (default `public`). The LAN scan behind `list` and flag-less selection always uses the driver's default community. | ### `print text` @@ -164,6 +166,7 @@ thermal-label print image label.png --rotate 90 --printer labelwriter | `--scale-y ` | `1` | Vertical scale factor. | | `--density ` | `normal` | Driver-specific density (`light`, `normal`, `dark`, …). | | `--copies ` | `1` | Number of copies. | +| `--no-confirm` | confirm on | Send without out-of-band print confirmation (network printers whose SNMP page counter cannot be read). | ### `print image` @@ -175,6 +178,7 @@ thermal-label print image label.png --rotate 90 --printer labelwriter | `--rotate ` | `0` | Rotation: `0`, `90`, `180`, or `270`. | | `--density ` | `normal` | Driver-specific density. | | `--copies ` | `1` | Number of copies. | +| `--no-confirm` | confirm on | Send without out-of-band print confirmation. | ## Network printers @@ -207,7 +211,11 @@ asks over SNMP (model, serial, state, loaded media) because port 9100 is write-only. When that is not possible (SNMP disabled, printer on another subnet with no broadcast, a model the driver does not list) `--device` names the model and `--media` names the roll, and the job goes out -without a status read. +without a status read. `--community` covers a printer whose SNMP +community is not `public`, but only on the `--host` path: the LAN scan +that `list` and flag-less selection run goes through each driver's +default discovery and asks with `public`, so a printer with a custom +community is reached with `--host`. Two-colour rolls (Brother DK-22251) cannot be told apart from plain 62 mm rolls over the network; the status shows a `Two-colour: not From 6265deef4ad416e5d92f2e72363069f5d23d9b1f Mon Sep 17 00:00:00 2001 From: mannes Date: Wed, 16 Sep 2026 02:06:26 +0200 Subject: [PATCH 13/13] build: pin contracts ^0.6.2; driver peer floors brother-ql-node >=0.6.2, labelwriter-node >=0.6.4, labelmanager-node >=0.6.0 --- package.json | 8 ++-- pnpm-lock.yaml | 110 ++++++++++++++++++++++++++++--------------------- 2 files changed, 68 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index bc2f911..d33914c 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "prettier": "@mbtech-nl/prettier-config", "dependencies": { "@mbtech-nl/bitmap": "^1.3.0", - "@thermal-label/contracts": "^0.5.0", + "@thermal-label/contracts": "^0.6.2", "chalk": "^5.3.0", "commander": "^12.1.0", "jpeg-js": "^0.4.4", @@ -72,9 +72,9 @@ "usb": "^2.14.0" }, "peerDependencies": { - "@thermal-label/brother-ql-node": ">=0.5.0", - "@thermal-label/labelmanager-node": ">=0.5.0", - "@thermal-label/labelwriter-node": ">=0.5.0" + "@thermal-label/brother-ql-node": ">=0.6.2", + "@thermal-label/labelmanager-node": ">=0.6.0", + "@thermal-label/labelwriter-node": ">=0.6.4" }, "peerDependenciesMeta": { "@thermal-label/brother-ql-node": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6541d49..66245c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,17 +12,17 @@ importers: specifier: ^1.3.0 version: 1.3.0 '@thermal-label/brother-ql-node': - specifier: '>=0.5.0' - version: 0.5.0 + specifier: '>=0.6.2' + version: 0.6.2(usb@2.17.0) '@thermal-label/contracts': - specifier: ^0.5.0 - version: 0.5.0 + specifier: ^0.6.2 + version: 0.6.2 '@thermal-label/labelmanager-node': - specifier: '>=0.5.0' - version: 0.5.0 + specifier: '>=0.6.0' + version: 0.6.0 '@thermal-label/labelwriter-node': - specifier: '>=0.5.0' - version: 0.5.0 + specifier: '>=0.6.4' + version: 0.6.4(@thermal-label/d1-core@0.6.1) chalk: specifier: ^5.3.0 version: 5.6.2 @@ -552,36 +552,45 @@ packages: cpu: [x64] os: [win32] - '@thermal-label/brother-ql-core@0.5.0': - resolution: {integrity: sha512-FxmDDz9mJxMisZqEWLdlTGEl1/YLMdNf8TLrF+7HGHqOnR8yRFfU0NJ9ZiEo4wORfKM78ZHbo77lHx1pHmmBWw==} + '@thermal-label/brother-ql-core@0.6.2': + resolution: {integrity: sha512-0aFtX7IyKzZ7rh5/ZdOLH88FCBTN6jx2oNfBA4UH2P7/NCSu4hqnltnCAsVDx1Pxbey2fRCzhDLvbX/tmKwutg==} engines: {node: '>=20.9.0'} - '@thermal-label/brother-ql-node@0.5.0': - resolution: {integrity: sha512-9fzpTLFAAo7qu/EfTclP9xlv30dWwgK85PDwqhJVxPrANW9sAUPjAixjLWaM05eQXQ3rkRsRo8s1GkVXKHy3eg==} + '@thermal-label/brother-ql-node@0.6.2': + resolution: {integrity: sha512-UTKvOAeSq5x7ePDzbsuKGq4J0GzWebGEDxOJZOi+110iAVdLhWE3qQyyfIUgZQqkO6YTtOdCrZ5so4dJgw31fQ==} engines: {node: '>=20.9.0'} - '@thermal-label/contracts@0.5.0': - resolution: {integrity: sha512-RurcHh2ncDqsgY2m7Z+W19b9CQsrYSPRmgLR60LOTilRiNgeSRlR6B1sG6OPEkyJ+OLUJtp11VAt2eHSbp2g8w==} + '@thermal-label/contracts@0.6.2': + resolution: {integrity: sha512-KAFR/+kUIvM3nKt3RWa3gwaJDXGOAAg+eXz+n86ZlvlBV782WMfOj7SpUgQqdBh5OYNP0LMLDHk0OxiqSMx+NA==} engines: {node: '>=20.9.0'} - '@thermal-label/labelmanager-core@0.5.0': - resolution: {integrity: sha512-fbA3SAFhnyiJQv6hYcXqaCZfOrjr+p7+hEdQUpVl0K3THRxIsGzNZ/IypHOuZIQxxpMr3yJZAaALt182ZdaoNQ==} + '@thermal-label/d1-core@0.6.1': + resolution: {integrity: sha512-8WiMfnjRBIqT5be/dsDDWm5kav2Lcocj0/9r4AMsht7EOyQxMzb2EsL6b9/QUNyBlMaUQAaxKKCuu5azt7DCfw==} engines: {node: '>=20.9.0'} - '@thermal-label/labelmanager-node@0.5.0': - resolution: {integrity: sha512-u1N8LJ8+qWbkXfvFKSp+L2GJJZU5fyUXoMGcg1N7vQcK63LCXtrwGz6NheRRvcVhd7Q04izwRiHTkJa3W4s/Qg==} + '@thermal-label/labelmanager-core@0.6.0': + resolution: {integrity: sha512-KyTQbPhqnFf3Mj8h+CIkUdiD/MzWHZGqDa7zm3hw4O3CtXL7mgat2dLtHX05dz4NxsnGYJNMN+b9PUJeKvc/wQ==} engines: {node: '>=20.9.0'} - '@thermal-label/labelwriter-core@0.5.0': - resolution: {integrity: sha512-brlut9Xt+UiwhWN06SUOisauPwYjypiSRM4+55C2TVeB6DwaMWm7RuNHgoGNWbVpISjLV4m5MHVyN1oMjihnQQ==} + '@thermal-label/labelmanager-node@0.6.0': + resolution: {integrity: sha512-Ubam8OQ6/VynXGDow0CJI9bdAiQtg6hmSLS13nQYzPQixCjdZITjmK5VO97w6+awODXVPL72b6dDT9RKdEiaYA==} engines: {node: '>=20.9.0'} - '@thermal-label/labelwriter-node@0.5.0': - resolution: {integrity: sha512-cwJD9CtL0InQvJwsPx7266JRxZ8UcLIHbdrssBO1HGYHucyFk6KTKoVGiG0ODtMN9wm2qWWBWDzu04/GngoQnQ==} + '@thermal-label/labelwriter-core@0.6.4': + resolution: {integrity: sha512-d1I7NahMj0YHZ7+9xZCzESZmiMp6mo5OYWebsLvsx8MElLxFD/ovVR8H9oxm+cuD/Mo82b40jc/PCS3+qwPxTw==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@thermal-label/d1-core': ^0.6.0 + peerDependenciesMeta: + '@thermal-label/d1-core': + optional: true + + '@thermal-label/labelwriter-node@0.6.4': + resolution: {integrity: sha512-6S13m4dJdDQyoOFf33rwQXOQ66iI3N4ullirv6ElWLpyDmpdJcNrzyN5jv5HildNSActLIQQCnicYmX/gSFVvg==} engines: {node: '>=20.9.0'} - '@thermal-label/transport@0.5.0': - resolution: {integrity: sha512-kHdVlNbMfxv4hLGZPCHKNTrKsjj6R0u29V0DQn0hFgwIZl0Xm4eWn01JeGhRr7Gv6dLHgc0TRINkp/3BM3bq6Q==} + '@thermal-label/transport@0.6.1': + resolution: {integrity: sha512-5ZhPZ4Ce77JOyzpSyTqF9nyEN+LkeReISQx2HVBI3j42M8vgYP090Wp16Qv0Dznuwtfd3WKeR3jSJv/VFWWAlQ==} engines: {node: '>=20.9.0'} peerDependencies: serialport: '>=12.0.0' @@ -2054,62 +2063,71 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true - '@thermal-label/brother-ql-core@0.5.0': + '@thermal-label/brother-ql-core@0.6.2': dependencies: '@mbtech-nl/bitmap': 1.3.0 - '@thermal-label/contracts': 0.5.0 + '@thermal-label/contracts': 0.6.2 - '@thermal-label/brother-ql-node@0.5.0': + '@thermal-label/brother-ql-node@0.6.2(usb@2.17.0)': dependencies: '@mbtech-nl/bitmap': 1.3.0 - '@thermal-label/brother-ql-core': 0.5.0 - '@thermal-label/contracts': 0.5.0 - '@thermal-label/transport': 0.5.0(usb@2.17.0) - usb: 2.17.0 + '@thermal-label/brother-ql-core': 0.6.2 + '@thermal-label/contracts': 0.6.2 + '@thermal-label/transport': 0.6.1(usb@2.17.0) optionalDependencies: '@napi-rs/canvas': 0.1.99 transitivePeerDependencies: - serialport + - usb - '@thermal-label/contracts@0.5.0': + '@thermal-label/contracts@0.6.2': dependencies: '@mbtech-nl/bitmap': 1.3.0 - '@thermal-label/labelmanager-core@0.5.0': + '@thermal-label/d1-core@0.6.1': dependencies: '@mbtech-nl/bitmap': 1.3.0 - '@thermal-label/contracts': 0.5.0 + '@thermal-label/contracts': 0.6.2 - '@thermal-label/labelmanager-node@0.5.0': + '@thermal-label/labelmanager-core@0.6.0': dependencies: - '@thermal-label/contracts': 0.5.0 - '@thermal-label/labelmanager-core': 0.5.0 - '@thermal-label/transport': 0.5.0(usb@2.17.0) + '@mbtech-nl/bitmap': 1.3.0 + '@thermal-label/contracts': 0.6.2 + '@thermal-label/d1-core': 0.6.1 + + '@thermal-label/labelmanager-node@0.6.0': + dependencies: + '@thermal-label/contracts': 0.6.2 + '@thermal-label/labelmanager-core': 0.6.0 + '@thermal-label/transport': 0.6.1(usb@2.17.0) usb: 2.17.0 optionalDependencies: '@napi-rs/canvas': 0.1.99 transitivePeerDependencies: - serialport - '@thermal-label/labelwriter-core@0.5.0': + '@thermal-label/labelwriter-core@0.6.4(@thermal-label/d1-core@0.6.1)': dependencies: '@mbtech-nl/bitmap': 1.3.0 - '@thermal-label/contracts': 0.5.0 + '@thermal-label/contracts': 0.6.2 + optionalDependencies: + '@thermal-label/d1-core': 0.6.1 - '@thermal-label/labelwriter-node@0.5.0': + '@thermal-label/labelwriter-node@0.6.4(@thermal-label/d1-core@0.6.1)': dependencies: - '@thermal-label/contracts': 0.5.0 - '@thermal-label/labelwriter-core': 0.5.0 - '@thermal-label/transport': 0.5.0(usb@2.17.0) + '@thermal-label/contracts': 0.6.2 + '@thermal-label/labelwriter-core': 0.6.4(@thermal-label/d1-core@0.6.1) + '@thermal-label/transport': 0.6.1(usb@2.17.0) usb: 2.17.0 optionalDependencies: '@napi-rs/canvas': 0.1.99 transitivePeerDependencies: + - '@thermal-label/d1-core' - serialport - '@thermal-label/transport@0.5.0(usb@2.17.0)': + '@thermal-label/transport@0.6.1(usb@2.17.0)': dependencies: - '@thermal-label/contracts': 0.5.0 + '@thermal-label/contracts': 0.6.2 optionalDependencies: usb: 2.17.0