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..4f5c75a 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 — 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 +- [ ] 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..5e495a3 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,20 @@ 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". `--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 externally and use `print image`. @@ -122,9 +150,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 opened with `--host` (default `public`). The LAN scan behind `list` and flag-less selection always uses the driver's default community. | ### `print text` @@ -135,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` @@ -146,6 +178,50 @@ 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 + +`--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 (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): + 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 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. +``` + +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. `--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 +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 diff --git a/package.json b/package.json index 737389d..d33914c 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", @@ -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" @@ -64,16 +64,17 @@ "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", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "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 c3b3b23..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 @@ -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 @@ -549,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.6.2': + resolution: {integrity: sha512-UTKvOAeSq5x7ePDzbsuKGq4J0GzWebGEDxOJZOi+110iAVdLhWE3qQyyfIUgZQqkO6YTtOdCrZ5so4dJgw31fQ==} engines: {node: '>=20.9.0'} - '@thermal-label/brother-ql-node@0.5.0': - resolution: {integrity: sha512-9fzpTLFAAo7qu/EfTclP9xlv30dWwgK85PDwqhJVxPrANW9sAUPjAixjLWaM05eQXQ3rkRsRo8s1GkVXKHy3eg==} + '@thermal-label/contracts@0.6.2': + resolution: {integrity: sha512-KAFR/+kUIvM3nKt3RWa3gwaJDXGOAAg+eXz+n86ZlvlBV782WMfOj7SpUgQqdBh5OYNP0LMLDHk0OxiqSMx+NA==} engines: {node: '>=20.9.0'} - '@thermal-label/contracts@0.5.0': - resolution: {integrity: sha512-RurcHh2ncDqsgY2m7Z+W19b9CQsrYSPRmgLR60LOTilRiNgeSRlR6B1sG6OPEkyJ+OLUJtp11VAt2eHSbp2g8w==} + '@thermal-label/d1-core@0.6.1': + resolution: {integrity: sha512-8WiMfnjRBIqT5be/dsDDWm5kav2Lcocj0/9r4AMsht7EOyQxMzb2EsL6b9/QUNyBlMaUQAaxKKCuu5azt7DCfw==} engines: {node: '>=20.9.0'} - '@thermal-label/labelmanager-core@0.5.0': - resolution: {integrity: sha512-fbA3SAFhnyiJQv6hYcXqaCZfOrjr+p7+hEdQUpVl0K3THRxIsGzNZ/IypHOuZIQxxpMr3yJZAaALt182ZdaoNQ==} + '@thermal-label/labelmanager-core@0.6.0': + resolution: {integrity: sha512-KyTQbPhqnFf3Mj8h+CIkUdiD/MzWHZGqDa7zm3hw4O3CtXL7mgat2dLtHX05dz4NxsnGYJNMN+b9PUJeKvc/wQ==} engines: {node: '>=20.9.0'} - '@thermal-label/labelmanager-node@0.5.0': - resolution: {integrity: sha512-u1N8LJ8+qWbkXfvFKSp+L2GJJZU5fyUXoMGcg1N7vQcK63LCXtrwGz6NheRRvcVhd7Q04izwRiHTkJa3W4s/Qg==} + '@thermal-label/labelmanager-node@0.6.0': + resolution: {integrity: sha512-Ubam8OQ6/VynXGDow0CJI9bdAiQtg6hmSLS13nQYzPQixCjdZITjmK5VO97w6+awODXVPL72b6dDT9RKdEiaYA==} engines: {node: '>=20.9.0'} - '@thermal-label/labelwriter-core@0.5.0': - resolution: {integrity: sha512-brlut9Xt+UiwhWN06SUOisauPwYjypiSRM4+55C2TVeB6DwaMWm7RuNHgoGNWbVpISjLV4m5MHVyN1oMjihnQQ==} + '@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.5.0': - resolution: {integrity: sha512-cwJD9CtL0InQvJwsPx7266JRxZ8UcLIHbdrssBO1HGYHucyFk6KTKoVGiG0ODtMN9wm2qWWBWDzu04/GngoQnQ==} + '@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' @@ -2051,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 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__/print-text.test.ts b/src/__tests__/print-text.test.ts index 6a2d501..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; @@ -231,3 +245,168 @@ 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); + }); + + 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(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('--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]); + 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 => + 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/__tests__/select.test.ts b/src/__tests__/select.test.ts new file mode 100644 index 0000000..c9b4733 --- /dev/null +++ b/src/__tests__/select.test.ts @@ -0,0 +1,347 @@ +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 { 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 { + 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 }; +} + +/** 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('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')), + ); + 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 '); + }); + + 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', () => { + 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..1d1b696 100644 --- a/src/__tests__/status.test.ts +++ b/src/__tests__/status.test.ts @@ -209,17 +209,64 @@ 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); + 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 () => { + 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); }); 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/print-image.ts b/src/commands/print-image.ts index f6e41a5..c1a14b6 100644 --- a/src/commands/print-image.ts +++ b/src/commands/print-image.ts @@ -1,19 +1,18 @@ 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 { runPrint, type OutFn } from './print.js'; import { 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; copies?: number; + /** `--no-confirm`: skip the driver's out-of-band print confirmation. */ + confirm?: false; importer?: DynamicImporter; readFileFn?: ReadFileFn; out?: OutFn; @@ -25,18 +24,7 @@ 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); @@ -46,44 +34,22 @@ export async function printImageCommand( return; } - let printer; - 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; - } - + let selection; 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'}.`)); + selection = await selectPrinter( + { invocation: `print image ${path}`, ...options }, + options.importer, + ); } 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).', - ), - ); + if (err instanceof SelectionError) { + out(chalk.red(err.message)); process.exitCode = 1; return; } - out(chalk.red(`Print failed: ${err instanceof Error ? err.message : String(err)}`)); - process.exitCode = 1; - } finally { - await printer.close(); + throw err; } + + 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 aa8f10c..c9bb9d5 100644 --- a/src/commands/print-text.ts +++ b/src/commands/print-text.ts @@ -1,19 +1,18 @@ 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 { runPrint, type OutFn } from './print.js'; import { 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; copies?: number; + /** `--no-confirm`: skip the driver's out-of-band print confirmation. */ + confirm?: false; importer?: DynamicImporter; out?: OutFn; } @@ -24,9 +23,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)); @@ -36,49 +41,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: ${err instanceof Error ? err.message : String(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, selection.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..abf5bad --- /dev/null +++ b/src/commands/print.ts @@ -0,0 +1,100 @@ +import chalk from 'chalk'; + +import type { + MediaDescriptor, + PrinterAdapter, + PrintOptions, + RawImageData, +} from '@thermal-label/contracts'; + +import type { LoadedDriver } from '../discovery.js'; + +import { errorMessage, isContractsError, 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; + /** `--no-confirm`: send blind even when the status query succeeded. */ + confirm?: false; +} + +/** + * 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); + + // 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 = {}; + if (options.confirm === false) printOpts.confirm = false; + try { + 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, without print confirmation.`, + ), + ); + printOpts.confirm = false; + } + + const copies = options.copies ?? 1; + 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 (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).', + ), + ); + 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..238a2b2 100644 --- a/src/commands/select.ts +++ b/src/commands/select.ts @@ -1,4 +1,13 @@ -import type { DiscoveredPrinter, OpenOptions } from '@thermal-label/contracts'; +import type { + DiscoveredPrinter, + MediaDescriptor, + OpenOptions, + PrinterAdapter, +} from '@thermal-label/contracts'; +import { + DeviceIdentificationRequiredError, + MediaNotSpecifiedError, +} from '@thermal-label/contracts'; import { discoverAll, @@ -13,10 +22,24 @@ 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; + /** + * 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; } @@ -27,16 +50,18 @@ export class SelectionError extends Error { } } +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; + 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( @@ -56,12 +81,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; - 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)); @@ -73,7 +93,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}'.`, ); } @@ -93,7 +113,82 @@ export async function selectPrinter( const opts: OpenOptions = {}; if (picked.serialNumber !== undefined) opts.serialNumber = picked.serialNumber; - return { driver, openOptions: opts }; + 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; + + // 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 } }; + } +} + +/** + * 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 { @@ -107,3 +202,83 @@ function formatMultiple(printers: readonly DiscoveredPrinter[]): string { lines.push('', 'Use --printer or --serial to pick one.'); 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 (isContractsError(error, '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); +} + +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; +} diff --git a/src/commands/status.ts b/src/commands/status.ts index 29b1195..7ea83ed 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; @@ -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,18 +28,19 @@ 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); 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 +50,7 @@ function printStatus( out: OutFn, printer: PrinterAdapter, status: PrinterStatus, + explicitMedia?: MediaDescriptor, host?: string, port?: number, ): void { @@ -56,7 +58,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 +71,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..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); @@ -28,6 +28,9 @@ interface CommanderStatusOpts { host?: string; port?: number; serial?: string; + device?: string; + media?: string; + community?: string; } interface CommanderPrintTextOpts extends CommanderStatusOpts { @@ -36,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 { @@ -45,6 +50,7 @@ interface CommanderPrintImageOpts extends CommanderStatusOpts { rotate?: 0 | 90 | 180 | 270; density?: string; copies?: number; + confirm?: boolean; } function buildStatusOptions(opts: CommanderStatusOpts): StatusCommandOptions { @@ -53,6 +59,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; } @@ -63,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; } @@ -74,9 +84,25 @@ 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; } +/** 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,48 +120,39 @@ 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) .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)); }); - 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') .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)); });