@qrdx/cli 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -24
  3. package/dist/index.js +475 -150
  4. package/package.json +37 -14
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Ritesh Bucha and QRdx contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,45 +1,64 @@
1
1
  # @qrdx/cli
2
2
 
3
- QR code generator CLI for developers. Generate styled, scannable QR codes as SVG or PNG — fully non-interactive, CI-friendly.
3
+ QR code generator CLI for developers. Generate styled, scannable QR codes as SVG, PNG or vector EPS. It runs non-interactively in scripts and CI, and guides you with prompts when run in a terminal. It renders with the same [`qrdx`](https://www.npmjs.com/package/qrdx) library as the [QRdx](https://qrdx.dev) web app.
4
+
5
+ > [!NOTE]
6
+ > This README documents `@qrdx/cli` 0.0.5, the version in this repository. npm still has 0.0.4, so `npx @qrdx/cli` runs 0.0.4 until 0.0.5 is published. 0.0.4 has the qrdx 0.0.46 styles and no `--format` or `--frame` options, and it writes SVG into a file named `qr.eps`. For EPS today, use the [image API](https://qrdx.dev/docs/developers/image-api): `curl -o qr.eps "https://api.qrdx.dev/v1/qr?data=https://example.com&format=eps"`. For frames, download from the [playground](https://qrdx.dev/playground). Run `npm view @qrdx/cli version` to see what npm installs.
4
7
 
5
8
  ## Install
6
9
 
7
- ```bash
8
- # Install the qrdx library package (includes the CLI binary)
9
- npm install -g qrdx
10
+ Run without installing:
10
11
 
11
- # Or install the standalone CLI package
12
- npm install -g @qrdx/cli
12
+ ```bash
13
+ npx @qrdx/cli generate "https://example.com" -o qr.svg
13
14
  ```
14
15
 
15
- Or run without installing:
16
+ Or install globally, which gives you the `qrdx` command:
16
17
 
17
18
  ```bash
18
- npx qrdx generate "https://example.com" -o qr.svg
19
+ npm install -g @qrdx/cli
20
+ qrdx generate "https://example.com" -o qr.svg
19
21
  ```
20
22
 
23
+ PNG output uses [sharp](https://sharp.pixelplumbing.com). It is an optional dependency that npm installs automatically when your platform supports it; if it is missing, SVG and EPS output still work and the CLI tells you how to fix PNG.
24
+
25
+ > The `qrdx` npm package is the React library. The CLI lives only in `@qrdx/cli`.
26
+
21
27
  ## Usage
22
28
 
23
29
  ```bash
24
30
  qrdx generate <data> [options]
25
31
  ```
26
32
 
33
+ `gen` and `g` are aliases for `generate`, and `qrdx <data>` works too.
34
+
27
35
  **Options**
28
36
 
29
37
  | Flag | Short | Default | Description |
30
38
  |------|-------|---------|-------------|
31
- | `--output` | `-o` | — | Output path — extension sets format (`.svg` or `.png`) |
32
- | `--size` | | `512` | Output pixel size |
39
+ | `--output` | `-o` | `qr.svg` | Output path; the extension sets the format (`.svg`, `.png` or `.eps`) |
40
+ | `--format` | | from the extension | `svg`, `png` or `eps`, for a file name without an extension |
41
+ | `--size` | | `512` | Output size: pixels for SVG and PNG, points (72 per inch) for EPS |
33
42
  | `--level` | `-l` | `Q` | Error correction: `L` `M` `Q` `H` |
34
43
  | `--body` | | `square` | Body dot pattern |
35
44
  | `--eye` | | `square` | Corner eye pattern |
36
45
  | `--dot` | | `square` | Corner dot pattern |
37
- | `--fg` | | `#000000` | Foreground color |
38
- | `--bg` | | `#ffffff` | Background color |
39
- | `--eye-color` | | — | Corner eye color override |
40
- | `--dot-color` | | — | Corner dot color override |
41
- | `--logo` | | — | Center logo URL |
46
+ | `--fg` | | `#000000` | Foreground color (hex or JSON gradient) |
47
+ | `--bg` | | `#ffffff` | Background color (hex or JSON gradient) |
48
+ | `--eye-color` | | matches `--fg` | Corner eye color (hex or JSON gradient) |
49
+ | `--dot-color` | | matches `--fg` | Corner dot color (hex or JSON gradient) |
50
+ | `--logo` | | — | Center logo image URL (use with `--level H`) |
51
+ | `--no-logo` | | — | Skip the logo prompt in interactive mode |
42
52
  | `--margin` | | `0` | Quiet zone margin (modules) |
53
+ | `--template` | | — | Decorative template: `Arrow` `StandardBox` `SquareBorder` `StrikedBox` `Halloween` |
54
+ | `--frame` | | — | Call-to-action frame: <!-- catalogue:FRAME_STYLES -->`none`, `banner-bottom`, `banner-top`, `border`, `rounded-border`, `speech-bubble`, `banner-top-bottom`, `speech-bubble-top`, `chat-bubble`, `circle`, `polaroid`, `phone`, `ticket`, `badge`, `envelope`, `tag`, `menu-card`, `sticker`, `gradient-border`, `focus`<!-- /catalogue --> |
55
+ | `--frame-text` | | `Scan me` | Frame label, up to 24 characters (`""` hides it) |
56
+ | `--frame-color` | | `#000000` | Frame color (hex) |
57
+ | `--frame-text-color` | | white on banners | Frame label color (hex) |
58
+ | `--help` | `-h` | | Show all options |
59
+ | `--version` | `-v` | | Show the version |
60
+
61
+ Invalid values (unknown pattern, template, frame, level, size, output extension, malformed gradient JSON, frame text over 24 characters or a non-hex frame color) exit with code `1` and a message listing the accepted values. `--frame-text` or a frame color without `--frame` uses `banner-bottom`. A frame adds space around the code, so the file is larger than `--size`.
43
62
 
44
63
  ## Examples
45
64
 
@@ -50,6 +69,9 @@ qrdx generate "https://qrdx.dev" -o qr.svg
50
69
  # High-res PNG for print
51
70
  qrdx generate "https://qrdx.dev" -o qr.png --size 2048 --level H
52
71
 
72
+ # Vector EPS for a print shop or Illustrator, 2 inches wide
73
+ qrdx generate "https://qrdx.dev" -o qr.eps --size 144
74
+
53
75
  # Branded style
54
76
  qrdx generate "https://qrdx.dev" \
55
77
  --body circle --eye rounded \
@@ -59,7 +81,17 @@ qrdx generate "https://qrdx.dev" \
59
81
  # With center logo
60
82
  qrdx generate "https://qrdx.dev" \
61
83
  --logo "https://qrdx.dev/logo.png" --level H \
62
- -o logo-qr.svg
84
+ -o logo-qr.png
85
+
86
+ # Gradient foreground
87
+ qrdx generate "https://qrdx.dev" -o gradient.svg \
88
+ --fg '{"type":"linear","stops":[{"color":"#6366f1","offset":0},{"color":"#ec4899","offset":100}],"angle":135}'
89
+
90
+ # Template
91
+ qrdx generate "https://qrdx.dev" --template Arrow -o arrow.svg
92
+
93
+ # "Scan me" frame
94
+ qrdx generate "https://qrdx.dev/menu" --frame banner-bottom --frame-text "Scan for the menu" -o menu.png
63
95
 
64
96
  # Wi-Fi QR
65
97
  qrdx generate "WIFI:T:WPA;S:MyNetwork;P:MyPassword;;" -o wifi.svg
@@ -70,25 +102,47 @@ qrdx generate
70
102
 
71
103
  ## Patterns
72
104
 
73
- **Body** (`--body`): `square` `circle` `circle-large` `diamond` `circle-mixed` `pacman` `rounded` `small-square` `vertical-line`
105
+ **Body** (`--body`): <!-- catalogue:BODY_PATTERN -->`circle`, `circle-large`, `square`, `diamond`, `circle-mixed`, `pacman`, `rounded`, `small-square`, `vertical-line`, `horizontal-line`, `liquid`, `liquid-horizontal`, `liquid-vertical`, `classy`, `classy-rounded`, `leaf`, `plus`, `hexagon`, `star`, `heart`, `random-dots`, `mosaic`, `octagon`<!-- /catalogue -->
106
+
107
+ **Eye** (`--eye`): <!-- catalogue:CORNER_EYE_PATTERNS -->`square`, `rounded`, `gear`, `circle`, `diya`, `extra-rounded`, `message`, `pointy`, `leaf`, `drop`, `octagon`, `bevel`, `squircle`, `cushion`, `shield`, `petal`, `leaf-reverse`<!-- /catalogue -->
108
+
109
+ **Dot** (`--dot`): <!-- catalogue:CORNER_EYE_DOT_PATTERNS -->`square`, `rounded`, `circle`, `message`, `message-reverse`, `diya`, `diya-reverse`, `rounded-triangle`, `leaf`, `drop`, `plus`, `cross-rounded`, `hexagon`, `octagon`, `flower`, `heart`, `squircle`, `dots`, `extra-rounded`, `leaf-reverse`, `drop-reverse`<!-- /catalogue -->
110
+
111
+ See them all at [qrdx.dev/docs/styles](https://qrdx.dev/docs/styles). Every option decodes in our tests; `square` and `rounded` eyes scan most reliably, and `circle` eyes may be missed by strict scanners. Curly eyes and diamond, star and banner dots were retired because they don't scan reliably.
112
+
113
+ ## Gradients
114
+
115
+ Pass a JSON object to `--fg`, `--bg`, `--eye-color` or `--dot-color`:
116
+
117
+ ```bash
118
+ # Linear (angle in degrees: 0 = left→right, 90 = top→bottom)
119
+ --fg '{"type":"linear","stops":[{"color":"#6366f1","offset":0},{"color":"#ec4899","offset":100}],"angle":135}'
120
+
121
+ # Radial
122
+ --bg '{"type":"radial","stops":[{"color":"#fef9c3","offset":0},{"color":"#fde047","offset":100}]}'
123
+ ```
124
+
125
+ Each gradient needs at least 2 stops; `offset` is a percentage from 0 to 100.
74
126
 
75
- **Eye** (`--eye`): `square` `rounded` `gear` `circle` `diya` `extra-rounded` `message` `pointy` `curly`
127
+ ## EPS
76
128
 
77
- **Dot** (`--dot`): `square` `rounded` `circle` `diamond` `message` `message-reverse` `diya` `diya-reverse` `rounded-triangle` `star` `banner`
129
+ `.eps` files are vector PostScript (EPSF-3.0) for print shops and design tools. `--size` is the width in points, so `--size 144` is 2 inches. Linear and radial gradients print as smooth PostScript Level 3 shadings, and there is no transparency. PNG and JPEG logos are embedded; other logo formats are converted with sharp when it is installed, and otherwise left out with a warning.
78
130
 
79
131
  ## Interactive mode
80
132
 
81
- When run in a TTY without flags, `qrdx generate` starts a guided prompt flow — output path, then optionally advanced customizations (patterns, colors, logo).
133
+ When run in a TTY without an output path or data, `qrdx generate` starts a guided prompt flow: data, output path, then optional customizations (patterns, colors, template, logo, margin). In CI (`CI=true`) or when stdin/stdout is not a TTY it never prompts.
82
134
 
83
135
  ## Requirements
84
136
 
85
- Node.js ≥ 18
137
+ Node.js ≥ 22
86
138
 
87
139
  ## Links
88
140
 
89
- - [qrdx.dev](https://qrdx.dev) — web app
90
- - [github.com/qrdx/qrdx](https://github.com/qrdx/qrdx) — source
141
+ - [CLI docs](https://qrdx.dev/docs/developers/cli)
142
+ - [qrdx.dev](https://qrdx.dev): the web app, with static and dynamic QR codes, hosted pages and scan analytics
143
+ - [Image API](https://qrdx.dev/docs/developers/image-api) and [MCP server](https://qrdx.dev/docs/developers/mcp): QR codes over HTTP or from AI assistants, with nothing to install
144
+ - [github.com/bucharitesh/qrdx](https://github.com/bucharitesh/qrdx): the source
91
145
 
92
146
  ## License
93
147
 
94
- MIT
148
+ [MIT](./LICENSE) © Ritesh Bucha and QRdx contributors. Versions before 0.0.5 were published under AGPL-3.0-or-later.
package/dist/index.js CHANGED
@@ -1,39 +1,90 @@
1
1
  #!/usr/bin/env node
2
- 'use strict';
3
-
4
- var fs = require('fs');
5
- var path = require('path');
6
- var p = require('@clack/prompts');
7
- var qrdx = require('qrdx');
8
- var react = require('react');
9
- var server = require('react-dom/server');
10
-
11
- function _interopNamespace(e) {
12
- if (e && e.__esModule) return e;
13
- var n = Object.create(null);
14
- if (e) {
15
- Object.keys(e).forEach(function (k) {
16
- if (k !== 'default') {
17
- var d = Object.getOwnPropertyDescriptor(e, k);
18
- Object.defineProperty(n, k, d.get ? d : {
19
- enumerable: true,
20
- get: function () { return e[k]; }
21
- });
22
- }
23
- });
24
- }
25
- n.default = e;
26
- return Object.freeze(n);
27
- }
28
-
29
- var p__namespace = /*#__PURE__*/_interopNamespace(p);
2
+ import { readFileSync, createReadStream, writeFileSync } from 'fs';
3
+ import { join, extname } from 'path';
4
+ import * as readline from 'readline';
5
+ import * as p from '@clack/prompts';
6
+ import { BODY_PATTERN, CORNER_EYE_PATTERNS, CORNER_EYE_DOT_PATTERNS, getTemplateIds, FRAME_STYLES, DEFAULT_FRAME_TEXT, FRAME_TEXT_MAX_LENGTH, DEFAULT_MARGIN, getFrameDimensions, colorConfigSchema, errorLevelSchema, QRCodeSVG, svgToEPS } from 'qrdx';
7
+ import { readFile } from 'fs/promises';
8
+ import { createElement } from 'react';
9
+ import { renderToStaticMarkup } from 'react-dom/server';
30
10
 
11
+ var OUTPUT_FORMATS = ["svg", "png", "eps"];
31
12
  function buildSVGString(props) {
32
- const element = react.createElement(qrdx.QRCodeSVG, {
13
+ const element = createElement(QRCodeSVG, {
33
14
  size: props.size ?? 512,
34
15
  ...props
35
16
  });
36
- return server.renderToStaticMarkup(element);
17
+ return renderToStaticMarkup(element);
18
+ }
19
+ async function loadLogo(src) {
20
+ if (src.startsWith("data:")) {
21
+ const comma = src.indexOf(",");
22
+ const payload = src.slice(comma + 1);
23
+ return src.slice(0, comma).endsWith(";base64") ? Buffer.from(payload, "base64") : Buffer.from(decodeURIComponent(payload));
24
+ }
25
+ if (/^https?:\/\//i.test(src)) {
26
+ const res = await fetch(src);
27
+ if (!res.ok) {
28
+ throw new Error(
29
+ `Could not download logo (${res.status} ${res.statusText}): ${src}`
30
+ );
31
+ }
32
+ return Buffer.from(await res.arrayBuffer());
33
+ }
34
+ return readFile(src);
35
+ }
36
+ async function logoAsEmbeddableDataUrl(src) {
37
+ const bytes = await loadLogo(src);
38
+ if (bytes[0] === 137 && bytes[1] === 80) {
39
+ return `data:image/png;base64,${bytes.toString("base64")}`;
40
+ }
41
+ if (bytes[0] === 255 && bytes[1] === 216) {
42
+ return `data:image/jpeg;base64,${bytes.toString("base64")}`;
43
+ }
44
+ try {
45
+ const mod = await import('sharp');
46
+ const sharp = mod.default;
47
+ const png = await sharp(bytes).png().toBuffer();
48
+ return `data:image/png;base64,${png.toString("base64")}`;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ async function buildEPSString(props) {
54
+ const src = props.imageSettings?.src;
55
+ let epsProps = props;
56
+ if (src && props.imageSettings) {
57
+ const dataUrl = await logoAsEmbeddableDataUrl(src);
58
+ if (dataUrl) {
59
+ epsProps = {
60
+ ...props,
61
+ imageSettings: { ...props.imageSettings, src: dataUrl }
62
+ };
63
+ } else {
64
+ console.warn(
65
+ "The logo could not be converted for EPS (PNG or JPEG work best), so it was left out."
66
+ );
67
+ }
68
+ }
69
+ return await svgToEPS(buildSVGString(epsProps));
70
+ }
71
+ async function findLogoBox(sharp, svgString) {
72
+ const imageTag = svgString.match(/<image\b[^>]*?(?:\/>|><\/image>)/)?.[0];
73
+ if (!imageTag) {
74
+ return null;
75
+ }
76
+ const markerTag = imageTag.replace(/^<image\b/, '<rect class="qrdx-logo-box"').replace(/\shref="[^"]*"/, "").replace(/(?:\/>|><\/image>)$/, ' fill="#000"/>');
77
+ const markerSvg = svgString.replace(imageTag, markerTag).replace(
78
+ /<svg\b[^>]*>/,
79
+ (open) => `${open}<style>*{visibility:hidden}.qrdx-logo-box{visibility:visible}</style>`
80
+ );
81
+ const { info } = await sharp(Buffer.from(markerSvg, "utf-8")).png().trim().toBuffer({ resolveWithObject: true });
82
+ return {
83
+ left: -(info.trimOffsetLeft ?? 0),
84
+ top: -(info.trimOffsetTop ?? 0),
85
+ width: info.width,
86
+ height: info.height
87
+ };
37
88
  }
38
89
  async function saveOutput(options) {
39
90
  const { outputPath, format, ...qrProps } = options;
@@ -41,13 +92,34 @@ async function saveOutput(options) {
41
92
  if (format === "svg") {
42
93
  const svgContent = `<?xml version="1.0" encoding="UTF-8"?>
43
94
  ${svgString}`;
44
- fs.writeFileSync(outputPath, svgContent, "utf-8");
95
+ writeFileSync(outputPath, svgContent, "utf-8");
96
+ return;
97
+ }
98
+ if (format === "eps") {
99
+ writeFileSync(outputPath, await buildEPSString(qrProps), "utf-8");
45
100
  return;
46
101
  }
47
- const sharp = (await import('sharp')).default;
102
+ let sharp;
103
+ try {
104
+ const mod = await import('sharp');
105
+ sharp = mod.default;
106
+ } catch {
107
+ throw new Error(
108
+ "PNG output needs the sharp package (native binaries). It is optional and may not have installed; use SVG or reinstall: https://sharp.pixelplumbing.com/install"
109
+ );
110
+ }
48
111
  const svgBuffer = Buffer.from(svgString, "utf-8");
49
112
  const size = qrProps.size ?? 512;
50
- await sharp(svgBuffer).resize(size, size).png().toFile(outputPath);
113
+ const { width, height } = getFrameDimensions(size, qrProps.frame);
114
+ const qrImage = sharp(svgBuffer).resize(Math.round(width), Math.round(height)).png();
115
+ const logoSrc = qrProps.imageSettings?.src;
116
+ const box = logoSrc ? await findLogoBox(sharp, svgString) : null;
117
+ if (!(logoSrc && box)) {
118
+ await qrImage.toFile(outputPath);
119
+ return;
120
+ }
121
+ const logo = await sharp(await loadLogo(logoSrc)).resize(box.width, box.height, { fit: "fill" }).png().toBuffer();
122
+ await sharp(await qrImage.toBuffer()).composite([{ input: logo, left: box.left, top: box.top }]).png().toFile(outputPath);
51
123
  }
52
124
 
53
125
  // src/commands/generate.ts
@@ -55,79 +127,217 @@ var RESET = "\x1B[0m";
55
127
  var TEXT = "\x1B[38;5;145m";
56
128
  var GREEN = "\x1B[38;5;114m";
57
129
  var DIM = "\x1B[38;5;102m";
130
+ var HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
131
+ var MAX_PNG_SIZE = 1e4;
58
132
  function parseColorFlag(value) {
59
133
  const trimmed = value.trim();
60
- if (trimmed.startsWith("{")) {
134
+ if (!trimmed.startsWith("{")) {
135
+ return trimmed;
136
+ }
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(trimmed);
140
+ } catch {
141
+ throw new Error(`Invalid gradient JSON: ${trimmed}`);
142
+ }
143
+ const result = colorConfigSchema.safeParse(parsed);
144
+ if (!result.success) {
145
+ throw new Error(
146
+ `Invalid gradient: ${trimmed}
147
+ Expected {"type":"linear"|"radial"|"angular","stops":[{"color":"#hex","offset":0-100}, ...]} with at least 2 stops.`
148
+ );
149
+ }
150
+ return result.data;
151
+ }
152
+ function assertOneOf(flag, value, allowed) {
153
+ if (value !== void 0 && !allowed.includes(value)) {
154
+ throw new Error(
155
+ `Invalid ${flag} "${value}". Expected one of: ${allowed.join(", ")}`
156
+ );
157
+ }
158
+ }
159
+ function parseIntegerFlag(flag, value, min) {
160
+ const parsed = Number(value);
161
+ if (!(value && Number.isInteger(parsed) && parsed >= min)) {
162
+ throw new Error(
163
+ `Invalid ${flag} "${value ?? ""}". Expected a whole number \u2265 ${min}.`
164
+ );
165
+ }
166
+ return parsed;
167
+ }
168
+ function assertHexColor(flag, value) {
169
+ if (value !== void 0 && !HEX_COLOR.test(value)) {
170
+ throw new Error(
171
+ `Invalid ${flag} "${value}". Expected a hex color like #000000.`
172
+ );
173
+ }
174
+ }
175
+ function requireValue(flag, value) {
176
+ if (value === void 0 || /^--?[a-z]/i.test(value)) {
177
+ throw new Error(`${flag} needs a value. Run qrdx --help to see options.`);
178
+ }
179
+ return value;
180
+ }
181
+ function frameFromFlags(flags) {
182
+ const style = flags.frame ?? (flags.frameText !== void 0 || flags.frameColor !== void 0 || flags.frameTextColor !== void 0 ? "banner-bottom" : void 0);
183
+ if (!style || style === "none") {
184
+ return void 0;
185
+ }
186
+ return {
187
+ style,
188
+ ...flags.frameText !== void 0 && { text: flags.frameText },
189
+ ...flags.frameColor && { color: flags.frameColor },
190
+ ...flags.frameTextColor && { textColor: flags.frameTextColor }
191
+ };
192
+ }
193
+ function validateFlags(flags) {
194
+ assertOneOf("--level", flags.level, errorLevelSchema.options);
195
+ assertOneOf("--body", flags.body, BODY_PATTERN);
196
+ assertOneOf("--eye", flags.eye, CORNER_EYE_PATTERNS);
197
+ assertOneOf("--dot", flags.dot, CORNER_EYE_DOT_PATTERNS);
198
+ assertOneOf(
199
+ "--template",
200
+ flags.template,
201
+ getTemplateIds().filter((id) => id !== "default")
202
+ );
203
+ assertOneOf("--frame", flags.frame, FRAME_STYLES);
204
+ if (flags.frameText !== void 0 && flags.frameText.length > FRAME_TEXT_MAX_LENGTH) {
205
+ throw new Error(
206
+ `Invalid --frame-text "${flags.frameText}". Use ${FRAME_TEXT_MAX_LENGTH} characters or fewer.`
207
+ );
208
+ }
209
+ assertHexColor("--frame-color", flags.frameColor);
210
+ assertHexColor("--frame-text-color", flags.frameTextColor);
211
+ assertOneOf("--format", flags.format, OUTPUT_FORMATS);
212
+ if (flags.output !== void 0) {
213
+ const ext = extname(flags.output).toLowerCase();
214
+ if (!["", ".svg", ".png", ".eps"].includes(ext)) {
215
+ throw new Error(
216
+ `Unsupported output format "${ext}". Use a .svg, .png or .eps file name.`
217
+ );
218
+ }
219
+ if (ext && flags.format && ext !== `.${flags.format}`) {
220
+ throw new Error(
221
+ `--format ${flags.format} does not match the output file "${flags.output}". Use a .${flags.format} file name or drop --format.`
222
+ );
223
+ }
224
+ }
225
+ const isPng = extname(flags.output ?? "").toLowerCase() === ".png" || flags.format === "png";
226
+ if (isPng && flags.size !== void 0 && flags.size > MAX_PNG_SIZE) {
227
+ throw new Error(
228
+ `Invalid --size "${flags.size}". PNG output is at most ${MAX_PNG_SIZE} px; use .svg or .eps for larger prints.`
229
+ );
230
+ }
231
+ }
232
+ var isCI = process.env.CI === "true";
233
+ var canUseClack = process.stdin.isTTY === true && process.stdout.isTTY === true && !isCI;
234
+ function readLineFromTTY(question) {
235
+ if (process.platform === "win32") {
236
+ return Promise.resolve(void 0);
237
+ }
238
+ return new Promise((resolve) => {
239
+ let stream;
61
240
  try {
62
- return JSON.parse(trimmed);
241
+ stream = createReadStream("/dev/tty", { flags: "r" });
63
242
  } catch {
243
+ resolve(void 0);
244
+ return;
64
245
  }
65
- }
66
- return trimmed;
246
+ const rl = readline.createInterface({
247
+ input: stream,
248
+ output: process.stdout
249
+ });
250
+ const finish = (answer) => {
251
+ rl.close();
252
+ stream.destroy();
253
+ resolve(answer);
254
+ };
255
+ stream.once("error", () => finish(void 0));
256
+ rl.question(question, (answer) => finish(answer));
257
+ });
67
258
  }
68
- var IS_TTY = Boolean(process.stdin.isTTY);
69
259
  function parseFlags(args) {
70
260
  const flags = {};
71
261
  let i = 0;
72
262
  while (i < args.length) {
73
263
  const arg = args[i];
74
264
  if (!arg.startsWith("-")) {
75
- if (!flags.value) {
76
- flags.value = arg;
265
+ if (flags.value !== void 0) {
266
+ throw new Error(
267
+ `Unexpected argument "${arg}". Quote text with spaces: qrdx generate "${flags.value} ${arg}"`
268
+ );
77
269
  }
270
+ flags.value = arg;
78
271
  i++;
79
272
  continue;
80
273
  }
81
274
  const next = args[i + 1];
82
275
  switch (arg) {
276
+ case "-h":
277
+ case "--help":
278
+ flags.help = true;
279
+ i++;
280
+ break;
281
+ case "-v":
282
+ case "--version":
283
+ flags.version = true;
284
+ i++;
285
+ break;
83
286
  case "-o":
84
287
  case "--output":
288
+ if (!next || /^--?[a-z]/i.test(next)) {
289
+ throw new Error(`${arg} needs a file path, e.g. ${arg} qr.svg`);
290
+ }
85
291
  flags.output = next;
86
292
  i += 2;
87
293
  break;
294
+ case "--format":
295
+ flags.format = requireValue(arg, next).toLowerCase();
296
+ i += 2;
297
+ break;
88
298
  case "-l":
89
299
  case "--level":
90
- flags.level = next;
300
+ flags.level = requireValue(arg, next).toUpperCase();
91
301
  i += 2;
92
302
  break;
93
303
  case "--body":
94
- flags.body = next;
304
+ flags.body = requireValue(arg, next);
95
305
  i += 2;
96
306
  break;
97
307
  case "--eye":
98
- flags.eye = next;
308
+ flags.eye = requireValue(arg, next);
99
309
  i += 2;
100
310
  break;
101
311
  case "--dot":
102
- flags.dot = next;
312
+ flags.dot = requireValue(arg, next);
103
313
  i += 2;
104
314
  break;
105
315
  case "--fg":
106
- flags.fg = next ? parseColorFlag(next) : void 0;
316
+ flags.fg = parseColorFlag(requireValue(arg, next));
107
317
  i += 2;
108
318
  break;
109
319
  case "--bg":
110
- flags.bg = next ? parseColorFlag(next) : void 0;
320
+ flags.bg = parseColorFlag(requireValue(arg, next));
111
321
  i += 2;
112
322
  break;
113
323
  case "--eye-color":
114
- flags.eyeColor = next ? parseColorFlag(next) : void 0;
324
+ flags.eyeColor = parseColorFlag(requireValue(arg, next));
115
325
  i += 2;
116
326
  break;
117
327
  case "--dot-color":
118
- flags.dotColor = next ? parseColorFlag(next) : void 0;
328
+ flags.dotColor = parseColorFlag(requireValue(arg, next));
119
329
  i += 2;
120
330
  break;
121
331
  case "--logo":
122
- flags.logo = next;
332
+ flags.logo = requireValue(arg, next);
123
333
  i += 2;
124
334
  break;
125
335
  case "--margin":
126
- flags.margin = next ? Number.parseInt(next, 10) : qrdx.DEFAULT_MARGIN;
336
+ flags.margin = parseIntegerFlag(arg, next, 0);
127
337
  i += 2;
128
338
  break;
129
339
  case "--size":
130
- flags.size = next ? Number.parseInt(next, 10) : 512;
340
+ flags.size = parseIntegerFlag(arg, next, 1);
131
341
  i += 2;
132
342
  break;
133
343
  case "--no-logo":
@@ -135,77 +345,143 @@ function parseFlags(args) {
135
345
  i++;
136
346
  break;
137
347
  case "--template":
138
- flags.template = next;
348
+ flags.template = requireValue(arg, next);
349
+ i += 2;
350
+ break;
351
+ case "--frame":
352
+ flags.frame = requireValue(arg, next);
353
+ i += 2;
354
+ break;
355
+ case "--frame-text":
356
+ flags.frameText = requireValue(arg, next);
357
+ i += 2;
358
+ break;
359
+ case "--frame-color":
360
+ flags.frameColor = requireValue(arg, next);
361
+ i += 2;
362
+ break;
363
+ case "--frame-text-color":
364
+ flags.frameTextColor = requireValue(arg, next);
139
365
  i += 2;
140
366
  break;
141
367
  default:
142
- i++;
368
+ throw new Error(
369
+ `Unknown option "${arg}". Run qrdx --help to see all options.`
370
+ );
143
371
  }
144
372
  }
373
+ if (!(flags.help || flags.version)) validateFlags(flags);
145
374
  return flags;
146
375
  }
147
376
  function isCancel2(value) {
148
- return p__namespace.isCancel(value);
377
+ return p.isCancel(value);
149
378
  }
150
379
  function handleCancel() {
151
- p__namespace.cancel("Cancelled.");
380
+ p.cancel("Cancelled.");
381
+ console.error(
382
+ `${DIM}Tip:${RESET} ${TEXT}qrdx generate "https://\u2026" -o qr.svg${RESET} \u2014 local dev: ${TEXT}pnpm qrdx -- generate \u2026${RESET}`
383
+ );
152
384
  process.exit(0);
153
385
  }
154
- function inferFormat(outputPath) {
155
- const ext = path.extname(outputPath).toLowerCase();
386
+ function inferFormat(outputPath, format) {
387
+ const ext = extname(outputPath).toLowerCase();
156
388
  if (ext === ".png") {
157
389
  return "png";
158
390
  }
159
- return "svg";
391
+ if (ext === ".eps") {
392
+ return "eps";
393
+ }
394
+ return ext === ".svg" ? "svg" : format ?? "svg";
160
395
  }
161
- async function runGenerate(args) {
396
+ async function runGenerate(args, meta) {
162
397
  const flags = parseFlags(args);
163
- const nonInteractive = !IS_TTY;
398
+ if (meta && flags.help) return meta.help();
399
+ if (meta && flags.version) return meta.version();
400
+ meta?.start?.();
401
+ const nonInteractive = !canUseClack;
164
402
  const hasAdvancedFlags = Boolean(
165
- flags.body || flags.eye || flags.dot || flags.fg || flags.bg || flags.eyeColor || flags.dotColor || flags.logo || flags.level || flags.margin !== void 0 || flags.template
403
+ flags.body || flags.eye || flags.dot || flags.fg || flags.bg || flags.eyeColor || flags.dotColor || flags.logo || flags.level || flags.margin !== void 0 || flags.template || frameFromFlags(flags)
166
404
  );
167
- p__namespace.intro(`${TEXT}qrdx${RESET} ${DIM}\u2014 QR Code Generator${RESET}`);
405
+ p.intro(`${TEXT}qrdx${RESET} ${DIM}\u2014 QR Code Generator${RESET}`);
406
+ if (canUseClack) {
407
+ process.stdin.resume();
408
+ }
168
409
  let value = flags.value;
169
410
  if (!value) {
170
- if (nonInteractive) {
171
- p__namespace.log.error("No data provided. Pass a URL or text as an argument.");
172
- process.exit(1);
173
- }
174
- const res = await p__namespace.text({
175
- message: "URL or text to encode",
176
- placeholder: "https://qrdx.dev",
177
- validate(v) {
178
- if (!v.trim()) {
179
- return "Please enter a value to encode.";
411
+ if (canUseClack) {
412
+ const promptStart = performance.now();
413
+ const res = await p.text({
414
+ message: "URL or text to encode",
415
+ placeholder: "https://qrdx.dev",
416
+ validate(v) {
417
+ if (!v?.trim()) {
418
+ return "Please enter a value to encode.";
419
+ }
420
+ }
421
+ });
422
+ if (isCancel2(res)) {
423
+ const instant = performance.now() - promptStart < 120 && process.platform !== "win32" && process.stdout.isTTY && !isCI;
424
+ if (instant) {
425
+ const line = await readLineFromTTY(
426
+ `${TEXT}URL or text to encode${RESET} ${DIM}(e.g. https://qrdx.dev)${RESET}: `
427
+ );
428
+ if (line?.trim()) {
429
+ value = line.trim();
430
+ } else {
431
+ handleCancel();
432
+ }
433
+ } else {
434
+ handleCancel();
180
435
  }
436
+ } else {
437
+ value = res;
181
438
  }
182
- });
183
- if (isCancel2(res)) {
184
- handleCancel();
439
+ } else if (process.stdout.isTTY && !isCI) {
440
+ const line = await readLineFromTTY(
441
+ `${TEXT}URL or text to encode${RESET} ${DIM}(e.g. https://qrdx.dev)${RESET}: `
442
+ );
443
+ if (line?.trim()) {
444
+ value = line.trim();
445
+ }
446
+ }
447
+ if (!value) {
448
+ console.error(
449
+ 'No data to encode. Example: npx @qrdx/cli generate "https://example.com" -o qr.svg'
450
+ );
451
+ process.exit(1);
185
452
  }
186
- value = res;
187
453
  }
188
454
  let outputPath = flags.output;
189
455
  const size = flags.size ?? 512;
190
- if (!(outputPath || nonInteractive)) {
191
- const pathRes = await p__namespace.text({
192
- message: "Output file (.svg or .png)",
193
- placeholder: "qr.svg",
194
- defaultValue: "qr.svg",
195
- validate(v) {
196
- if (!v.trim()) {
197
- return "Please enter an output path.";
456
+ const defaultOutput = `qr.${flags.format ?? "svg"}`;
457
+ if (!outputPath) {
458
+ if (canUseClack) {
459
+ const pathRes = await p.text({
460
+ message: "Output file (.svg, .png or .eps)",
461
+ placeholder: defaultOutput,
462
+ defaultValue: defaultOutput,
463
+ validate(v) {
464
+ if (!v?.trim()) {
465
+ return "Please enter an output path.";
466
+ }
198
467
  }
468
+ });
469
+ if (isCancel2(pathRes)) {
470
+ handleCancel();
199
471
  }
200
- });
201
- if (isCancel2(pathRes)) {
202
- handleCancel();
472
+ outputPath = pathRes || defaultOutput;
473
+ } else if (process.stdout.isTTY && !isCI) {
474
+ const pathAns = await readLineFromTTY(
475
+ `Output file ${DIM}(.svg, .png or .eps, Enter = ${defaultOutput})${RESET}: `
476
+ );
477
+ outputPath = pathAns?.trim() || defaultOutput;
478
+ } else {
479
+ outputPath = defaultOutput;
203
480
  }
204
- outputPath = pathRes || "qr.svg";
205
481
  }
206
482
  let wantAdvanced = hasAdvancedFlags;
207
483
  if (!(hasAdvancedFlags || nonInteractive)) {
208
- const res = await p__namespace.confirm({
484
+ const res = await p.confirm({
209
485
  message: "Advanced customizations?",
210
486
  initialValue: false
211
487
  });
@@ -223,11 +499,12 @@ async function runGenerate(args) {
223
499
  let eyeColor = flags.eyeColor;
224
500
  let dotColor = flags.dotColor;
225
501
  let logo = flags.logo;
226
- let margin = flags.margin ?? qrdx.DEFAULT_MARGIN;
502
+ let margin = flags.margin ?? DEFAULT_MARGIN;
227
503
  let template = flags.template;
504
+ let frame = frameFromFlags(flags);
228
505
  if (wantAdvanced && !nonInteractive) {
229
506
  if (!flags.level) {
230
- const res = await p__namespace.select({
507
+ const res = await p.select({
231
508
  message: "Error correction level",
232
509
  options: [
233
510
  {
@@ -247,9 +524,9 @@ async function runGenerate(args) {
247
524
  level = res;
248
525
  }
249
526
  if (!flags.body) {
250
- const res = await p__namespace.select({
527
+ const res = await p.select({
251
528
  message: "Body dot pattern",
252
- options: qrdx.BODY_PATTERN.map((b) => ({ value: b, label: b })),
529
+ options: BODY_PATTERN.map((b) => ({ value: b, label: b })),
253
530
  initialValue: "square"
254
531
  });
255
532
  if (isCancel2(res)) {
@@ -258,9 +535,9 @@ async function runGenerate(args) {
258
535
  body = res;
259
536
  }
260
537
  if (!flags.eye) {
261
- const res = await p__namespace.select({
538
+ const res = await p.select({
262
539
  message: "Corner eye pattern",
263
- options: qrdx.CORNER_EYE_PATTERNS.map((e) => ({ value: e, label: e })),
540
+ options: CORNER_EYE_PATTERNS.map((e) => ({ value: e, label: e })),
264
541
  initialValue: "square"
265
542
  });
266
543
  if (isCancel2(res)) {
@@ -269,9 +546,9 @@ async function runGenerate(args) {
269
546
  eye = res;
270
547
  }
271
548
  if (!flags.dot) {
272
- const res = await p__namespace.select({
549
+ const res = await p.select({
273
550
  message: "Corner dot pattern",
274
- options: qrdx.CORNER_EYE_DOT_PATTERNS.map((d) => ({ value: d, label: d })),
551
+ options: CORNER_EYE_DOT_PATTERNS.map((d) => ({ value: d, label: d })),
275
552
  initialValue: "square"
276
553
  });
277
554
  if (isCancel2(res)) {
@@ -280,13 +557,13 @@ async function runGenerate(args) {
280
557
  dot = res;
281
558
  }
282
559
  if (!flags.fg) {
283
- const res = await p__namespace.text({
560
+ const res = await p.text({
284
561
  message: `Foreground color ${DIM}(hex or JSON gradient)${RESET}`,
285
562
  placeholder: "#000000",
286
563
  defaultValue: "#000000",
287
564
  validate(v) {
288
565
  if (!v) {
289
- return void 0;
566
+ return;
290
567
  }
291
568
  if (v.trim().startsWith("{")) {
292
569
  try {
@@ -294,7 +571,7 @@ async function runGenerate(args) {
294
571
  } catch {
295
572
  return "Enter a valid hex color or JSON gradient object";
296
573
  }
297
- return void 0;
574
+ return;
298
575
  }
299
576
  if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
300
577
  return "Enter a valid hex color (e.g. #000000) or JSON gradient";
@@ -307,13 +584,13 @@ async function runGenerate(args) {
307
584
  fg = parseColorFlag(res || "#000000");
308
585
  }
309
586
  if (!flags.bg) {
310
- const res = await p__namespace.text({
587
+ const res = await p.text({
311
588
  message: `Background color ${DIM}(hex or JSON gradient)${RESET}`,
312
589
  placeholder: "#ffffff",
313
590
  defaultValue: "#ffffff",
314
591
  validate(v) {
315
592
  if (!v) {
316
- return void 0;
593
+ return;
317
594
  }
318
595
  if (v.trim().startsWith("{")) {
319
596
  try {
@@ -321,7 +598,7 @@ async function runGenerate(args) {
321
598
  } catch {
322
599
  return "Enter a valid hex color or JSON gradient object";
323
600
  }
324
- return void 0;
601
+ return;
325
602
  }
326
603
  if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
327
604
  return "Enter a valid hex color (e.g. #ffffff) or JSON gradient";
@@ -334,12 +611,12 @@ async function runGenerate(args) {
334
611
  bg = parseColorFlag(res || "#ffffff");
335
612
  }
336
613
  if (eyeColor === void 0) {
337
- const res = await p__namespace.text({
614
+ const res = await p.text({
338
615
  message: `Corner eye color ${DIM}(leave blank to match foreground)${RESET}`,
339
616
  placeholder: "leave blank",
340
617
  validate(v) {
341
618
  if (!v) {
342
- return void 0;
619
+ return;
343
620
  }
344
621
  if (v.trim().startsWith("{")) {
345
622
  try {
@@ -347,7 +624,7 @@ async function runGenerate(args) {
347
624
  } catch {
348
625
  return "Enter a valid hex color or JSON gradient object";
349
626
  }
350
- return void 0;
627
+ return;
351
628
  }
352
629
  if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
353
630
  return "Enter a valid hex color or leave blank";
@@ -361,12 +638,12 @@ async function runGenerate(args) {
361
638
  eyeColor = eyeVal ? parseColorFlag(eyeVal) : void 0;
362
639
  }
363
640
  if (dotColor === void 0) {
364
- const res = await p__namespace.text({
641
+ const res = await p.text({
365
642
  message: `Corner dot color ${DIM}(leave blank to match foreground)${RESET}`,
366
643
  placeholder: "leave blank",
367
644
  validate(v) {
368
645
  if (!v) {
369
- return void 0;
646
+ return;
370
647
  }
371
648
  if (v.trim().startsWith("{")) {
372
649
  try {
@@ -374,7 +651,7 @@ async function runGenerate(args) {
374
651
  } catch {
375
652
  return "Enter a valid hex color or JSON gradient object";
376
653
  }
377
- return void 0;
654
+ return;
378
655
  }
379
656
  if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
380
657
  return "Enter a valid hex color or leave blank";
@@ -388,8 +665,8 @@ async function runGenerate(args) {
388
665
  dotColor = dotVal ? parseColorFlag(dotVal) : void 0;
389
666
  }
390
667
  if (!flags.template) {
391
- const templateIds = qrdx.getTemplateIds();
392
- const res = await p__namespace.select({
668
+ const templateIds = getTemplateIds();
669
+ const res = await p.select({
393
670
  message: `Template ${DIM}(decorative wrapper around the QR code)${RESET}`,
394
671
  options: [
395
672
  { value: "", label: "none" },
@@ -402,8 +679,37 @@ async function runGenerate(args) {
402
679
  }
403
680
  template = res || void 0;
404
681
  }
682
+ if (flags.frame === void 0 && !frame) {
683
+ const res = await p.select({
684
+ message: `Frame ${DIM}(call-to-action label around the code)${RESET}`,
685
+ options: FRAME_STYLES.map((style) => ({ value: style, label: style })),
686
+ initialValue: "none"
687
+ });
688
+ if (isCancel2(res)) {
689
+ handleCancel();
690
+ }
691
+ if (res !== "none") {
692
+ const text2 = await p.text({
693
+ message: `Frame text ${DIM}(up to ${FRAME_TEXT_MAX_LENGTH} characters)${RESET}`,
694
+ placeholder: DEFAULT_FRAME_TEXT,
695
+ defaultValue: DEFAULT_FRAME_TEXT,
696
+ validate(v) {
697
+ if (v && v.length > FRAME_TEXT_MAX_LENGTH) {
698
+ return `Use ${FRAME_TEXT_MAX_LENGTH} characters or fewer`;
699
+ }
700
+ }
701
+ });
702
+ if (isCancel2(text2)) {
703
+ handleCancel();
704
+ }
705
+ frame = {
706
+ style: res,
707
+ text: text2 || DEFAULT_FRAME_TEXT
708
+ };
709
+ }
710
+ }
405
711
  if (logo === void 0 && !flags.noLogo) {
406
- const addLogo = await p__namespace.confirm({
712
+ const addLogo = await p.confirm({
407
713
  message: "Add a logo to the center?",
408
714
  initialValue: false
409
715
  });
@@ -411,16 +717,14 @@ async function runGenerate(args) {
411
717
  handleCancel();
412
718
  }
413
719
  if (addLogo) {
414
- const res = await p__namespace.text({
720
+ const res = await p.text({
415
721
  message: "Logo URL",
416
722
  placeholder: "https://qrdx.dev/logo.png",
417
723
  validate(v) {
418
- if (!v.trim()) {
724
+ if (!v?.trim()) {
419
725
  return "Please enter a URL for the logo.";
420
726
  }
421
- try {
422
- new URL(v);
423
- } catch {
727
+ if (!URL.canParse(v)) {
424
728
  return "Please enter a valid URL.";
425
729
  }
426
730
  }
@@ -432,10 +736,10 @@ async function runGenerate(args) {
432
736
  }
433
737
  }
434
738
  if (flags.margin === void 0) {
435
- const res = await p__namespace.text({
739
+ const res = await p.text({
436
740
  message: "Quiet zone margin (modules)",
437
- placeholder: String(qrdx.DEFAULT_MARGIN),
438
- defaultValue: String(qrdx.DEFAULT_MARGIN),
741
+ placeholder: String(DEFAULT_MARGIN),
742
+ defaultValue: String(DEFAULT_MARGIN),
439
743
  validate(v) {
440
744
  if (v && Number.isNaN(Number.parseInt(v, 10))) {
441
745
  return "Enter a number";
@@ -448,10 +752,10 @@ async function runGenerate(args) {
448
752
  if (isCancel2(res)) {
449
753
  handleCancel();
450
754
  }
451
- margin = Number.parseInt(res || String(qrdx.DEFAULT_MARGIN), 10);
755
+ margin = Number.parseInt(res || String(DEFAULT_MARGIN), 10);
452
756
  }
453
757
  }
454
- const spinner2 = p__namespace.spinner();
758
+ const spinner2 = p.spinner();
455
759
  spinner2.start("Generating QR code...");
456
760
  try {
457
761
  const qrProps = {
@@ -467,6 +771,7 @@ async function runGenerate(args) {
467
771
  margin,
468
772
  size,
469
773
  ...template ? { templateId: template } : {},
774
+ ...frame ? { frame } : {},
470
775
  ...logo ? {
471
776
  imageSettings: {
472
777
  src: logo,
@@ -477,21 +782,22 @@ async function runGenerate(args) {
477
782
  } : {}
478
783
  };
479
784
  if (outputPath) {
480
- const format = inferFormat(outputPath);
785
+ const format = inferFormat(outputPath, flags.format);
481
786
  await saveOutput({ ...qrProps, outputPath, format });
787
+ const dimensions = getFrameDimensions(size, frame);
482
788
  spinner2.stop(
483
- `${GREEN}\u2714${RESET} Saved to ${TEXT}${outputPath}${RESET} ${DIM}(${size}\xD7${size})${RESET}`
789
+ `${GREEN}\u2714${RESET} Saved to ${TEXT}${outputPath}${RESET} ${DIM}(${Math.round(dimensions.width)}\xD7${Math.round(dimensions.height)})${RESET}`
484
790
  );
485
791
  } else {
486
792
  spinner2.stop("QR code ready");
487
793
  }
488
794
  } catch (err) {
489
- spinner2.stop("Failed to generate QR code");
795
+ spinner2.error("Failed to generate QR code");
490
796
  const msg = err instanceof Error ? err.message : String(err);
491
- p__namespace.log.error(msg);
797
+ p.log.error(msg);
492
798
  process.exit(1);
493
799
  }
494
- p__namespace.outro(`${DIM}Done!${RESET}`);
800
+ p.outro(`${DIM}Done!${RESET}`);
495
801
  }
496
802
 
497
803
  // src/index.ts
@@ -517,8 +823,8 @@ var LOGO_LINES = [
517
823
  ];
518
824
  function getVersion() {
519
825
  try {
520
- const pkgPath = path.join(__dirname, "..", "package.json");
521
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
826
+ const pkgPath = join(import.meta.dirname, "..", "package.json");
827
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
522
828
  return pkg.version;
523
829
  } catch {
524
830
  return "0.0.1";
@@ -563,32 +869,44 @@ ${BOLD}Commands:${RESET2}
563
869
 
564
870
  ${BOLD}Generate Options:${RESET2}
565
871
  <data> URL or text to encode (positional)
566
- -o, --output Output file path (.svg or .png)
872
+ -o, --output Output file path (.svg, .png or .eps)
873
+ --format Output format when the file name has no extension:
874
+ ${DIM2}svg, png, eps${RESET2} ${DIM2}(EPS is vector; --size = width in points)${RESET2}
567
875
  -l, --level Error correction: L, M, Q, H ${DIM2}(default: Q)${RESET2}
568
- --body Body dot pattern:
569
- ${DIM2}square, circle, circle-large, diamond,${RESET2}
570
- ${DIM2}circle-mixed, pacman, rounded, small-square, vertical-line${RESET2}
571
- --eye Corner eye pattern:
572
- ${DIM2}square, rounded, gear, circle, diya,${RESET2}
573
- ${DIM2}extra-rounded, message, pointy, curly${RESET2}
574
- --dot Corner dot pattern:
575
- ${DIM2}square, rounded, circle, diamond, message,${RESET2}
576
- ${DIM2}message-reverse, diya, diya-reverse,${RESET2}
577
- ${DIM2}rounded-triangle, star, banner${RESET2}
876
+ --body Body dot pattern, e.g.:
877
+ ${DIM2}square, rounded, circle, circle-large, liquid,${RESET2}
878
+ ${DIM2}classy, classy-rounded, leaf, hexagon, star, heart${RESET2}
879
+ --eye Corner eye pattern, e.g.:
880
+ ${DIM2}square, rounded, extra-rounded, gear, leaf, drop,${RESET2}
881
+ ${DIM2}squircle, octagon, shield, petal${RESET2}
882
+ --dot Corner dot pattern, e.g.:
883
+ ${DIM2}square, rounded, circle, leaf, drop, squircle,${RESET2}
884
+ ${DIM2}octagon, flower, heart, plus, dots${RESET2}
885
+ ${DIM2}All options: https://qrdx.dev/docs/styles${RESET2}
578
886
  --fg Foreground color \u2014 hex or JSON gradient ${DIM2}(default: #000000)${RESET2}
579
887
  --bg Background color \u2014 hex or JSON gradient ${DIM2}(default: #ffffff)${RESET2}
580
888
  --eye-color Corner eye color \u2014 hex or JSON gradient ${DIM2}(default: matches --fg)${RESET2}
581
889
  --dot-color Corner dot color \u2014 hex or JSON gradient ${DIM2}(default: matches --fg)${RESET2}
582
- --logo Logo URL for center image
890
+ --logo Logo URL for center image (use with --level H)
891
+ --no-logo Skip the logo prompt in interactive mode
583
892
  --margin Quiet zone margin in modules ${DIM2}(default: 0)${RESET2}
584
893
  --size Output pixel size for SVG/PNG ${DIM2}(default: 512)${RESET2}
585
894
  --template Decorative template wrapper:
586
895
  ${DIM2}Arrow, StandardBox, SquareBorder, StrikedBox, Halloween${RESET2}
896
+ --frame Call-to-action frame (adds space around the code), e.g.:
897
+ ${DIM2}none, banner-bottom, banner-top, rounded-border,${RESET2}
898
+ ${DIM2}speech-bubble, polaroid, phone, ticket, sticker${RESET2}
899
+ ${DIM2}All frames: https://qrdx.dev/docs/styles${RESET2}
900
+ --frame-text Frame label, up to 24 characters ${DIM2}(default: Scan me; "" hides it)${RESET2}
901
+ --frame-color Frame color \u2014 hex ${DIM2}(default: #000000)${RESET2}
902
+ --frame-text-color Frame label color \u2014 hex ${DIM2}(default: white on banners)${RESET2}
903
+ ${DIM2}Frame text/colors without --frame use banner-bottom${RESET2}
587
904
 
588
905
  ${BOLD}Gradient Colors:${RESET2}
589
906
  Pass a JSON object to --fg / --bg / --eye-color / --dot-color for gradients:
590
907
  ${DIM2}Linear: '{"type":"linear","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}],"angle":45}'${RESET2}
591
908
  ${DIM2}Radial: '{"type":"radial","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}]}'${RESET2}
909
+ ${DIM2}Angular: '{"type":"angular","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}],"angle":0}'${RESET2}
592
910
 
593
911
  ${BOLD}Global Options:${RESET2}
594
912
  --help, -h Show this help message
@@ -598,11 +916,13 @@ ${BOLD}Examples:${RESET2}
598
916
  ${DIM2}$${RESET2} qrdx generate
599
917
  ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev"
600
918
  ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.svg
601
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.png --body circle --eye gear --dot star
919
+ ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.png --body circle --eye gear --dot flower
920
+ ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.eps --size 144
602
921
  ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --fg "#ff0000" --bg "#ffffff" --size 1024
603
922
  ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --logo "https://qrdx.dev/logo.png" --level H -o branded.svg
604
923
  ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --template Arrow -o arrow.svg
605
924
  ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --template Halloween --body circle --eye gear -o halloween.svg
925
+ ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --frame banner-bottom --frame-text "Scan for the menu" -o menu.png
606
926
  ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --fg '{"type":"linear","stops":[{"color":"#6366f1","offset":0},{"color":"#ec4899","offset":100}],"angle":135}' -o gradient.svg
607
927
 
608
928
  Visit ${TEXT2}https://qrdx.dev${RESET2} for more.
@@ -616,13 +936,19 @@ async function main() {
616
936
  }
617
937
  const command = args[0];
618
938
  const restArgs = args.slice(1);
939
+ const generateMeta = {
940
+ help: showHelp,
941
+ version: () => console.log(VERSION),
942
+ start: () => {
943
+ showLogo();
944
+ console.log();
945
+ }
946
+ };
619
947
  switch (command) {
620
948
  case "generate":
621
949
  case "gen":
622
950
  case "g":
623
- showLogo();
624
- console.log();
625
- await runGenerate(restArgs);
951
+ await runGenerate(restArgs, generateMeta);
626
952
  break;
627
953
  case "--help":
628
954
  case "-h":
@@ -635,12 +961,11 @@ async function main() {
635
961
  break;
636
962
  default:
637
963
  if (command.startsWith("-")) {
638
- console.log(`Unknown command: ${command}`);
639
- console.log(`Run ${BOLD}qrdx --help${RESET2} for usage.`);
964
+ console.error(`Unknown command: ${command}`);
965
+ console.error(`Run ${BOLD}qrdx --help${RESET2} for usage.`);
966
+ process.exitCode = 1;
640
967
  } else {
641
- showLogo();
642
- console.log();
643
- await runGenerate(args);
968
+ await runGenerate(args, generateMeta);
644
969
  }
645
970
  }
646
971
  }
package/package.json CHANGED
@@ -1,7 +1,29 @@
1
1
  {
2
2
  "name": "@qrdx/cli",
3
- "version": "0.0.3",
4
- "description": "QR code generator CLI for developers",
3
+ "version": "0.0.5",
4
+ "description": "QR code generator CLI for developers: styled SVG, PNG and vector EPS QR codes from the command line",
5
+ "keywords": [
6
+ "qr-code",
7
+ "qrcode",
8
+ "qr-code-generator",
9
+ "cli",
10
+ "svg",
11
+ "png",
12
+ "eps",
13
+ "qr-code-cli"
14
+ ],
15
+ "homepage": "https://qrdx.dev/docs",
16
+ "bugs": {
17
+ "url": "https://github.com/bucharitesh/qrdx/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/bucharitesh/qrdx.git",
22
+ "directory": "packages/cli"
23
+ },
24
+ "license": "MIT",
25
+ "author": "Ritesh Bucha",
26
+ "type": "module",
5
27
  "bin": {
6
28
  "qrdx": "./dist/index.js"
7
29
  },
@@ -10,31 +32,32 @@
10
32
  "README.md"
11
33
  ],
12
34
  "dependencies": {
13
- "@clack/prompts": "^0.10.0",
35
+ "@clack/prompts": "^1.8.1",
14
36
  "picocolors": "^1.1.1",
15
- "react": "^19.0.0",
16
- "react-dom": "^19.0.0",
17
- "qrdx": "0.0.46"
37
+ "react": "19.3.1",
38
+ "react-dom": "19.3.1",
39
+ "qrdx": "0.0.47"
18
40
  },
19
41
  "optionalDependencies": {
20
- "sharp": "^0.34.2"
42
+ "sharp": "^0.35.5"
21
43
  },
22
44
  "devDependencies": {
23
- "@types/node": "^25.1.0",
24
- "@types/react": "19.2.7",
25
- "@types/react-dom": "19.2.3",
26
- "tsup": "^8.5.1",
27
- "typescript": "5.9.3",
28
- "vitest": "^4.0.18",
45
+ "@types/node": "^26.6.6",
46
+ "@types/react": "19.3.0",
47
+ "@types/react-dom": "19.3.0",
48
+ "tsup": "^9.0.0",
49
+ "typescript": "6.0.3",
50
+ "vitest": "^5.0.5",
29
51
  "@repo/typescript-config": "0.0.0"
30
52
  },
31
53
  "engines": {
32
- "node": ">=18"
54
+ "node": ">=22"
33
55
  },
34
56
  "publishConfig": {
35
57
  "access": "public"
36
58
  },
37
59
  "scripts": {
60
+ "qrdx": "node ./dist/index.js",
38
61
  "build": "tsup",
39
62
  "dev": "tsup --watch",
40
63
  "test": "vitest run",