@bcts/seedtool-cli 1.0.0-alpha.23 → 1.0.0-beta.0

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.
package/src/main.ts CHANGED
@@ -13,9 +13,13 @@ import { SSKRGroupSpec } from "@bcts/components";
13
13
  import { Cli, type InputFormatKey, type OutputFormatKey, type SSKRFormatKey } from "./cli.js";
14
14
  import { selectInputFormat, selectOutputFormat } from "./formats/index.js";
15
15
  import { DeterministicRandomNumberGenerator } from "./random.js";
16
- import fs from "node:fs";
16
+ import pkg from "../package.json" with { type: "json" };
17
17
 
18
- const VERSION = "0.4.0";
18
+ /**
19
+ * Package version, sourced from `package.json` so the CLI's `--version` output
20
+ * never drifts from the published version.
21
+ */
22
+ const VERSION: string = pkg.version;
19
23
 
20
24
  /**
21
25
  * CLI options parsed from commander.
@@ -37,6 +41,78 @@ interface CliOptions {
37
41
  deterministic?: string;
38
42
  }
39
43
 
44
+ /**
45
+ * Build an argParser that validates a value against the given choices and,
46
+ * on failure, emits the clap-style error format used by Rust's seedtool-cli:
47
+ *
48
+ * error: invalid value 'X' for '--<long> <UPPER>'
49
+ * [possible values: a, b, c]
50
+ *
51
+ * For more information, try '--help'.
52
+ *
53
+ * `metavar` is the value-name placeholder (e.g. INPUT_TYPE), normally derived
54
+ * from the option's `<META>` declaration. Used in conjunction with `.choices()`
55
+ * for help-text generation — argParser runs first, so on a bad value we exit
56
+ * before commander's own choice-validation message can fire.
57
+ */
58
+ function clapChoiceParser<T extends string>(
59
+ longName: string,
60
+ metavar: string,
61
+ choices: readonly T[],
62
+ ): (value: string) => T {
63
+ return (value: string): T => {
64
+ if ((choices as readonly string[]).includes(value)) return value as T;
65
+ process.stderr.write(
66
+ `error: invalid value '${value}' for '--${longName} <${metavar}>'\n` +
67
+ ` [possible values: ${choices.join(", ")}]\n\n` +
68
+ `For more information, try '--help'.\n`,
69
+ );
70
+ process.exit(2);
71
+ };
72
+ }
73
+
74
+ // Choice arrays match Rust's clap `ValueEnum` declaration order so that
75
+ // `--help` and the `[possible values: …]` block in error output line up
76
+ // byte-identically. See seedtool-cli-rust/src/cli.rs.
77
+ const IN_CHOICES = [
78
+ "random",
79
+ "hex",
80
+ "btw",
81
+ "btwu",
82
+ "btwm",
83
+ "bits",
84
+ "cards",
85
+ "dice",
86
+ "base6",
87
+ "base10",
88
+ "ints",
89
+ "bip39",
90
+ "sskr",
91
+ "envelope",
92
+ "multipart",
93
+ "seed",
94
+ ] as const;
95
+
96
+ const OUT_CHOICES = [
97
+ "hex",
98
+ "btw",
99
+ "btwu",
100
+ "btwm",
101
+ "bits",
102
+ "cards",
103
+ "dice",
104
+ "base6",
105
+ "base10",
106
+ "ints",
107
+ "bip39",
108
+ "sskr",
109
+ "envelope",
110
+ "multipart",
111
+ "seed",
112
+ ] as const;
113
+
114
+ const SSKR_FORMAT_CHOICES = ["envelope", "btw", "btwm", "btwu", "ur"] as const;
115
+
40
116
  function parseLowInt(value: string): number {
41
117
  const num = parseInt(value, 10);
42
118
  if (isNaN(num) || num < 0 || num > 254) {
@@ -88,10 +164,10 @@ function main(): void {
88
164
  "Report bugs to ChristopherA@BlockchainCommons.com.\n" +
89
165
  "© 2024 Blockchain Commons.",
90
166
  )
91
- .version(VERSION)
167
+ .version(`@bcts/seedtool-cli ${VERSION}`)
92
168
  .argument(
93
169
  "[INPUT]",
94
- "The input to be transformed. If required and not present, it will be read from stdin.",
170
+ "The input to be transformed. If required and not present, it will be read from stdin",
95
171
  )
96
172
  .option(
97
173
  "-c, --count <COUNT>",
@@ -101,54 +177,23 @@ function main(): void {
101
177
  .addOption(
102
178
  new Option(
103
179
  "-i, --in <INPUT_TYPE>",
104
- "The input format. If not specified, a new random seed is generated using a secure random number generator.",
180
+ "The input format. If not specified, a new random seed is generated using a secure random number generator",
105
181
  )
106
- .choices([
107
- "random",
108
- "hex",
109
- "btw",
110
- "btwm",
111
- "btwu",
112
- "bits",
113
- "cards",
114
- "dice",
115
- "base6",
116
- "base10",
117
- "ints",
118
- "bip39",
119
- "sskr",
120
- "envelope",
121
- "seed",
122
- "multipart",
123
- ])
182
+ .choices([...IN_CHOICES])
183
+ .argParser(clapChoiceParser("in", "INPUT_TYPE", IN_CHOICES))
124
184
  .default("random"),
125
185
  )
126
186
  .addOption(
127
- new Option("-o, --out <OUTPUT_TYPE>", "The output format.")
128
- .choices([
129
- "hex",
130
- "btw",
131
- "btwm",
132
- "btwu",
133
- "bits",
134
- "cards",
135
- "dice",
136
- "base6",
137
- "base10",
138
- "ints",
139
- "bip39",
140
- "sskr",
141
- "envelope",
142
- "seed",
143
- "multipart",
144
- ])
187
+ new Option("-o, --out <OUTPUT_TYPE>", "The output format")
188
+ .choices([...OUT_CHOICES])
189
+ .argParser(clapChoiceParser("out", "OUTPUT_TYPE", OUT_CHOICES))
145
190
  .default("hex"),
146
191
  )
147
192
  .option("--low <LOW>", "The lowest int returned (0-254)", parseLowInt, 0)
148
193
  .option("--high <HIGH>", "The highest int returned (1-255), low < high", parseHighInt, 9)
149
- .option("--name <NAME>", "The name of the seed.")
150
- .option("--note <NOTE>", "The note associated with the seed.")
151
- .option("--date <DATE>", "The seed's creation date, in ISO-8601 format. May also be `now`.")
194
+ .option("--name <NAME>", "The name of the seed")
195
+ .option("--note <NOTE>", "The note associated with the seed")
196
+ .option("--date <DATE>", "The seed's creation date, in ISO-8601 format. May also be `now`")
152
197
  .option(
153
198
  "--max-fragment-len <MAX_FRAG_LEN>",
154
199
  "For `multipart` output, the UR will be segmented into parts with fragments no larger than MAX_FRAG_LEN",
@@ -156,7 +201,7 @@ function main(): void {
156
201
  )
157
202
  .option(
158
203
  "--additional-parts <NUM_PARTS>",
159
- "For `multipart` output, the number of additional parts above the minimum to generate using fountain encoding.",
204
+ "For `multipart` output, the number of additional parts above the minimum to generate using fountain encoding",
160
205
  "0",
161
206
  )
162
207
  .option(
@@ -167,18 +212,19 @@ function main(): void {
167
212
  )
168
213
  .option(
169
214
  "-t, --group-threshold <THRESHOLD>",
170
- "The number of groups that must meet their threshold. Must be <= the number of group specifications.",
215
+ "The number of groups that must meet their threshold. Must be <= the number of group specifications",
171
216
  parseGroupThreshold,
172
217
  1,
173
218
  )
174
219
  .addOption(
175
- new Option("-s, --sskr-format <SSKR_FORMAT>", "SSKR output format.")
176
- .choices(["envelope", "btw", "btwm", "btwu", "ur"])
220
+ new Option("-s, --sskr-format <SSKR_FORMAT>", "Output format")
221
+ .choices([...SSKR_FORMAT_CHOICES])
222
+ .argParser(clapChoiceParser("sskr-format", "SSKR_FORMAT", SSKR_FORMAT_CHOICES))
177
223
  .default("envelope"),
178
224
  )
179
225
  .option(
180
226
  "-d, --deterministic <SEED_STRING>",
181
- "Use a deterministic random number generator with the given seed string. Output generated from this seed will be the same every time, so generated seeds are only as secure as the seed string.",
227
+ "Use a deterministic random number generator with the given seed string. Output generated from this seed will be the same every time, so generated seeds are only as secure as the seed string",
182
228
  );
183
229
 
184
230
  program.parse();
@@ -189,12 +235,12 @@ function main(): void {
189
235
  // Create the CLI state
190
236
  const cli = new Cli();
191
237
 
192
- // Set input from argument or stdin
238
+ // Set input from positional argv. Stdin is NOT read here — the active input
239
+ // format calls `cli.expectInput()` lazily, mirroring Rust's `expect_input()`.
240
+ // This keeps deterministic flows (--in random, -d <SEED>, etc.) from
241
+ // blocking on stdin in non-TTY contexts (CI, sub-processes).
193
242
  if (args.length > 0) {
194
243
  cli.input = args[0];
195
- } else if (process.stdin.isTTY !== true) {
196
- // Read from stdin if it's piped
197
- cli.input = fs.readFileSync(process.stdin.fd, "utf-8").trim();
198
244
  }
199
245
 
200
246
  // Set options
@@ -249,6 +295,9 @@ try {
249
295
  main();
250
296
  } catch (error: unknown) {
251
297
  const message = error instanceof Error ? error.message : String(error);
252
- console.error(message);
298
+ // Mirrors Rust's anyhow `Error: {msg}` stderr format. Avoid double-prefixing
299
+ // if a deeper layer already emitted "Error: " (e.g. roundtrippability check).
300
+ const prefixed = message.startsWith("Error: ") ? message : `Error: ${message}`;
301
+ console.error(prefixed);
253
302
  process.exit(1);
254
303
  }
package/src/util.ts CHANGED
@@ -19,23 +19,41 @@ export function dataToHex(bytes: Uint8Array): string {
19
19
 
20
20
  /**
21
21
  * Convert hex string to bytes.
22
- * Matches Rust hex_to_data function.
22
+ *
23
+ * Mirrors the error wording produced by Rust's `hex` crate
24
+ * (`FromHexError::OddLength` and `FromHexError::InvalidHexCharacter`),
25
+ * which seedtool-cli-rust surfaces unchanged through anyhow:
26
+ *
27
+ * "Odd number of digits"
28
+ * "Invalid character '{c}' at position {n}"
29
+ *
30
+ * The outer CLI layer adds the `Error: ` prefix to match Rust's anyhow
31
+ * output.
23
32
  */
24
33
  export function hexToData(hex: string): Uint8Array {
25
34
  if (hex.length % 2 !== 0) {
26
- throw new Error("Hex string must have even length");
35
+ throw new Error("Odd number of digits");
27
36
  }
28
37
  const bytes = new Uint8Array(hex.length / 2);
29
38
  for (let i = 0; i < hex.length; i += 2) {
30
- const byte = parseInt(hex.substring(i, i + 2), 16);
31
- if (isNaN(byte)) {
32
- throw new Error(`Invalid hex character at position ${i}`);
33
- }
34
- bytes[i / 2] = byte;
39
+ const hi = hexCharToNibble(hex, i);
40
+ const lo = hexCharToNibble(hex, i + 1);
41
+ bytes[i / 2] = (hi << 4) | lo;
35
42
  }
36
43
  return bytes;
37
44
  }
38
45
 
46
+ function hexCharToNibble(hex: string, index: number): number {
47
+ const c = hex.charCodeAt(index);
48
+ // 0..9
49
+ if (c >= 0x30 && c <= 0x39) return c - 0x30;
50
+ // a..f
51
+ if (c >= 0x61 && c <= 0x66) return c - 0x61 + 10;
52
+ // A..F
53
+ if (c >= 0x41 && c <= 0x46) return c - 0x41 + 10;
54
+ throw new Error(`Invalid character '${hex[index]}' at position ${index}`);
55
+ }
56
+
39
57
  /**
40
58
  * Convert byte values to a different base range [0, base-1].
41
59
  * Each byte (0-255) is scaled proportionally to the target base.
@@ -136,7 +154,9 @@ export function digitsToData(inStr: string, low: number, high: number): Uint8Arr
136
154
  for (const c of inStr) {
137
155
  const n = c.charCodeAt(0) - "0".charCodeAt(0);
138
156
  if (n < low || n > high) {
139
- throw new Error(`Invalid digit: ${c}. Expected range [${low}-${high}].`);
157
+ // Mirrors Rust util.rs:64 (`bail!("Invalid digit.")`). The terser wording
158
+ // is intentional — if Rust's diagnostic is broadened upstream, mirror here.
159
+ throw new Error("Invalid digit.");
140
160
  }
141
161
  result.push(n);
142
162
  }