@drbaher/draft-cli 0.2.0 → 0.3.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,72 @@ All notable changes to this project will be documented in this file. The
4
4
  format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
5
5
  and the project adheres to semantic versioning once it leaves 0.x.
6
6
 
7
+ ## 0.3.2 — 2026-05-16
8
+
9
+ ### Fixed
10
+
11
+ - **Publish auth: restored `NODE_AUTH_TOKEN` env block.** v0.3.1's
12
+ hotfix bumped `publish.yml` Node from 20 to 22 on the hypothesis
13
+ that npm CLI 11.5.1+ would auto-detect OIDC and ignore the
14
+ setup-node placeholder. It didn't: Node 22's npm 11.x still sent
15
+ the literal `XXXXX-XXXXX-XXXXX-XXXXX` placeholder and got the
16
+ same 404 from npm. Root cause is *not* Node version; either
17
+ `setup-node@v6` always writes the placeholder env into the
18
+ publish step, or npm CLI prefers the `.npmrc` token over OIDC
19
+ even when both are available. Under investigation.
20
+ - **v0.3.1 tag exists on GitHub but did NOT publish to npm.** Skip
21
+ it. Registry latest is `0.2.0` until v0.3.2 ships.
22
+
23
+ Pragmatic call: v0.3.2 ships via the bootstrap `NPM_TOKEN` path.
24
+ Trusted Publisher stays configured on npm so the switch back to
25
+ pure OIDC is a one-line change in `publish.yml` once we understand
26
+ why npm CLI isn't using OIDC. `feedback_oidc_setup_node_v6_placeholder.md`
27
+ in memory tracks the symptom + workarounds tried.
28
+
29
+ ## 0.3.1 — 2026-05-16
30
+
31
+ ### Fixed
32
+
33
+ - **`publish.yml` now uses Node 22 instead of Node 20.** npm
34
+ Trusted Publishing requires npm CLI 11.5.1 or later, which ships
35
+ with Node 22.14+. Node 20 (npm 10.x) silently falls back to
36
+ `NODE_AUTH_TOKEN` when configured for a registry, and to
37
+ `setup-node@v6`'s placeholder value (`XXXXX-...`) when the env
38
+ isn't set — producing a 404 from npm masking the actual 401.
39
+ - **v0.3.0 tag exists on GitHub but did NOT publish to npm.** v0.3.0
40
+ was the first publish attempt without an `NPM_TOKEN` fallback
41
+ (PR #10 reverted the bootstrap); the npm-CLI-too-old issue
42
+ surfaced immediately. No package was uploaded. v0.3.1 is the
43
+ rebrand of v0.3.0's typed-parameter feature with the workflow
44
+ fix applied. Skip v0.3.0.
45
+
46
+ ## 0.3.0 — 2026-05-16
47
+
48
+ ### Added
49
+
50
+ - **Typed parameters (`type: date | money | party`).** Long-form
51
+ schema entries can declare a `type`, with optional `format` (date)
52
+ or `currency` (money). Inputs are validated and normalized between
53
+ value resolution and substitution; bad inputs hard-error with a
54
+ per-key message (exit 4). See `PARAM_SCHEMA.md` §5 for the accepted
55
+ shapes per type, the rejected ambiguous forms (Q3.1: US
56
+ `MM/DD/YYYY` and European `DD/MM/YYYY` are rejected as ambiguous),
57
+ and the v2 currency scope (Q3.2: USD only).
58
+ - **`--validate` now catches type errors** before draft runs. With
59
+ `--json`, errors are emitted as a `type_errors` array on the
60
+ result payload.
61
+ - **New public API:** `parseDateValue(raw)`, `formatDateValue(date, fmt)`,
62
+ `parseMoneyValue(raw)` → minor units, `formatMoneyValue(minor, currency)`,
63
+ `normalizeTypedValue(raw, placeholder)`, `normalizeTypedValues(placeholders, resolved)`.
64
+
65
+ ### Schema-contract change
66
+
67
+ `PARAM_SCHEMA.md` §5 gains a "Typed parameters" section. Long-form
68
+ entries can now include `type`, `format`, and `currency` fields; short
69
+ form is unchanged. v0.3.0 schemas are forward-compatible with v0.2.x
70
+ readers (which will silently ignore the new fields, since they're
71
+ opt-in metadata on the long-form entry).
72
+
7
73
  ## 0.2.0 — 2026-05-16
8
74
 
9
75
  ### Added
package/PARAM_SCHEMA.md CHANGED
@@ -237,6 +237,39 @@ its own alias list (Q3 locked) — list it explicitly if needed.
237
237
  Parser selects long form iff a top-level `_meta` key is present. Short
238
238
  and long are not mixable within one file.
239
239
 
240
+ ### Typed parameters (v0.3.0, opt-in)
241
+
242
+ Long-form entries can declare `type`, with optional `format` (`date`)
243
+ or `currency` (`money`). Inputs are validated and normalized between
244
+ value resolution and substitution. Bad input → exit 4
245
+ (`EXIT.VALIDATION`) with a per-key error message; all type errors are
246
+ collected before exit so the user sees every issue at once.
247
+
248
+ ```json
249
+ {
250
+ "_meta": { "schema_version": 1 },
251
+ "effective_date": { "aliases": ["Effective Date"], "type": "date", "format": "MMMM d, yyyy" },
252
+ "purchase_amount": { "aliases": ["Purchase Amount"], "type": "money", "currency": "USD" },
253
+ "party_a": { "aliases": ["Party A"], "type": "party" }
254
+ }
255
+ ```
256
+
257
+ | `type` | Accepts | Normalizes to | Notes |
258
+ | -------- | -------------------------------------------------------------------- | -------------------------------------- | ----- |
259
+ | `date` | ISO (`2027-01-15`) or spelled (`January 15, 2027`, `Jan 15 2027`) | `format` field (default `MMMM d, yyyy`) | Q3.1 locked: US (`MM/DD/YYYY`) and European (`DD/MM/YYYY`) numeric forms are **rejected** as ambiguous. |
260
+ | `money` | `$5,000`, `5000.50`, `$5M`, `2.5K`, `1B`; rejects `$$5`, `5,00`, etc. | `currency`-formatted (e.g. `$5,000,000.00`) | Q3.2 locked: v0.3.0 supports `currency: "USD"` only. |
261
+ | `party` | Non-empty after trim; no markdown links `[text](url)`; no trailing punctuation `.,;:!?` | Trimmed string | Q3.3 locked: hard error on bad input — typed params are opt-in. |
262
+
263
+ `format` for `date` supports `yyyy`, `MMMM`, `MM`, `d` tokens (matched
264
+ in a single pass — `MM` doesn't accidentally consume `MMMM`, `d`
265
+ doesn't leak into month names). Other literal characters pass through
266
+ unchanged. Other tokens (e.g. `HH:mm`, `dd`, `EEEE`) are deferred to
267
+ future versions.
268
+
269
+ Programmatic API for drivers: `parseDateValue`, `formatDateValue`,
270
+ `parseMoneyValue`, `formatMoneyValue`, `normalizeTypedValue`,
271
+ `normalizeTypedValues`.
272
+
240
273
  ### Orphan handling (Q4 locked)
241
274
 
242
275
  Schema declares a key whose alias list matches no detected phrase →
package/draft-cli.mjs CHANGED
@@ -70,7 +70,7 @@ import { fileURLToPath } from "node:url";
70
70
  */
71
71
 
72
72
  /** @type {string} */
73
- export const VERSION = "0.2.0";
73
+ export const VERSION = "0.3.2";
74
74
 
75
75
  // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
76
  /**
@@ -918,6 +918,12 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
918
918
  aliases: v.aliases.slice(),
919
919
  required: v.required !== false,
920
920
  default: Object.prototype.hasOwnProperty.call(v, "default") ? v.default : null,
921
+ // v2 #3: typed parameters. `type` is one of `date|money|party` (or
922
+ // absent → no validation/normalization). `format` (date) and
923
+ // `currency` (money) are optional.
924
+ type: typeof v.type === "string" ? v.type : null,
925
+ format: typeof v.format === "string" ? v.format : null,
926
+ currency: typeof v.currency === "string" ? v.currency : null,
921
927
  };
922
928
  } else {
923
929
  if (!Array.isArray(v)) {
@@ -925,7 +931,7 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
925
931
  e.exitCode = EXIT.IO;
926
932
  throw e;
927
933
  }
928
- entries[k] = { aliases: v.slice(), required: true, default: null };
934
+ entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null };
929
935
  }
930
936
  }
931
937
  return { form: long ? "long" : "short", entries };
@@ -1066,6 +1072,9 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1066
1072
  required: resolved.required,
1067
1073
  default: resolved.default,
1068
1074
  aliases: resolved.aliases,
1075
+ type: resolved.type,
1076
+ format: resolved.format,
1077
+ currency: resolved.currency,
1069
1078
  hits: [],
1070
1079
  });
1071
1080
  }
@@ -1080,14 +1089,22 @@ function resolveKey(hit, schema, fromLlm) {
1080
1089
  if (schema) {
1081
1090
  for (const [key, entry] of Object.entries(schema.entries)) {
1082
1091
  if (entry.aliases.includes(hit.inner)) {
1083
- return { key, required: entry.required, default: entry.default, aliases: entry.aliases };
1092
+ return {
1093
+ key,
1094
+ required: entry.required,
1095
+ default: entry.default,
1096
+ aliases: entry.aliases,
1097
+ type: entry.type || null,
1098
+ format: entry.format || null,
1099
+ currency: entry.currency || null,
1100
+ };
1084
1101
  }
1085
1102
  }
1086
1103
  return null;
1087
1104
  }
1088
1105
  const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
1089
1106
  if (!validKey(key)) return null;
1090
- return { key, required: true, default: null, aliases: [hit.inner] };
1107
+ return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null };
1091
1108
  }
1092
1109
 
1093
1110
  // ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
@@ -1165,6 +1182,218 @@ export async function resolveValues(placeholders, opts, paramsObj, { prompter =
1165
1182
  return { resolved, missing, sources };
1166
1183
  }
1167
1184
 
1185
+ // ─── TYPED-PARAMETER NORMALIZATION (v2 #3) ──────────────────────────────────
1186
+ // Schema entries can declare `type: date | money | party` with optional
1187
+ // `format` (date) or `currency` (money). Inputs are validated and normalized
1188
+ // after value resolution and before substitution. Hard error (exit 4) on
1189
+ // invalid input — typed params are opt-in; the user asked for validation.
1190
+
1191
+ const MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
1192
+ "July", "August", "September", "October", "November", "December"];
1193
+ const MONTH_INDEX = (() => {
1194
+ const m = {};
1195
+ MONTH_NAMES.forEach((name, i) => {
1196
+ m[name.toLowerCase()] = i;
1197
+ m[name.slice(0, 3).toLowerCase()] = i;
1198
+ });
1199
+ // "Sept" is a common 4-letter abbrev.
1200
+ m.sept = 8;
1201
+ return m;
1202
+ })();
1203
+
1204
+ /**
1205
+ * Parse a date input. Accepts ISO `YYYY-MM-DD` or spelled
1206
+ * `Month D, YYYY` / `Mon D YYYY`. Returns a UTC `Date` on success or `null`
1207
+ * on failure. Q3.1: US (`MM/DD/YYYY`) and European (`DD/MM/YYYY`) numeric
1208
+ * formats are NOT accepted — they're ambiguous and footgun-y. Use ISO for
1209
+ * machine input, spelled for human input.
1210
+ *
1211
+ * @param {string} raw
1212
+ * @returns {Date | null}
1213
+ */
1214
+ export function parseDateValue(raw) {
1215
+ const s = String(raw).trim();
1216
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
1217
+ if (iso) {
1218
+ const [, y, m, d] = iso;
1219
+ const date = new Date(Date.UTC(+y, +m - 1, +d));
1220
+ // Reject impossible dates (e.g. 2026-02-31 round-trips to 2026-03-03).
1221
+ if (date.getUTCFullYear() !== +y || date.getUTCMonth() !== +m - 1 || date.getUTCDate() !== +d) return null;
1222
+ return date;
1223
+ }
1224
+ const spelled = /^([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})$/.exec(s);
1225
+ if (spelled) {
1226
+ const month = MONTH_INDEX[spelled[1].toLowerCase()];
1227
+ if (month === undefined) return null;
1228
+ const date = new Date(Date.UTC(+spelled[3], month, +spelled[2]));
1229
+ if (date.getUTCMonth() !== month || date.getUTCDate() !== +spelled[2]) return null;
1230
+ return date;
1231
+ }
1232
+ return null;
1233
+ }
1234
+
1235
+ /**
1236
+ * Format a `Date` per a simple format string. Supported tokens:
1237
+ * `yyyy` (year), `MMMM` (full month name), `MM` (2-digit month), `d` (day).
1238
+ * Order doesn't matter; tokens are matched in a single pass so MMMM doesn't
1239
+ * accidentally consume MM, and `d` doesn't leak into month names.
1240
+ *
1241
+ * @param {Date} date
1242
+ * @param {string} format
1243
+ * @returns {string}
1244
+ */
1245
+ export function formatDateValue(date, format) {
1246
+ const y = date.getUTCFullYear();
1247
+ const m = date.getUTCMonth();
1248
+ const d = date.getUTCDate();
1249
+ return format.replace(/yyyy|MMMM|MM|d/g, (token) => {
1250
+ if (token === "yyyy") return String(y);
1251
+ if (token === "MMMM") return MONTH_NAMES[m];
1252
+ if (token === "MM") return String(m + 1).padStart(2, "0");
1253
+ if (token === "d") return String(d);
1254
+ return token;
1255
+ });
1256
+ }
1257
+
1258
+ /**
1259
+ * Parse a money input. Accepts `$5,000`, `5000.50`, `$5M`, `2.5K`, etc.
1260
+ * Handles `K`/`M`/`B` suffixes (case-insensitive). Returns the value in
1261
+ * minor units (cents for USD) as an integer, or `null` on failure.
1262
+ *
1263
+ * @param {string} raw
1264
+ * @returns {number | null}
1265
+ */
1266
+ export function parseMoneyValue(raw) {
1267
+ const s = String(raw).trim();
1268
+ if (!s) return null;
1269
+ // Strict shape: optional minus, optional single $, digits (with optional
1270
+ // thousand-comma groups), optional decimal, optional K/M/B. Rejects
1271
+ // doubled `$`, ad-hoc comma placement, multiple decimals, words.
1272
+ if (!/^-?\$?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?[KMB]?$/i.test(s)) return null;
1273
+ let core = s.replace(/[$,\s]/g, "");
1274
+ let mult = 1;
1275
+ if (/[KMB]$/i.test(core)) {
1276
+ mult = { K: 1e3, M: 1e6, B: 1e9 }[core.slice(-1).toUpperCase()];
1277
+ core = core.slice(0, -1);
1278
+ }
1279
+ const n = parseFloat(core);
1280
+ if (!isFinite(n)) return null;
1281
+ return Math.round(n * mult * 100);
1282
+ }
1283
+
1284
+ /**
1285
+ * Format a money value (in minor units, e.g. cents for USD) per a currency.
1286
+ * Q3.2: v2 supports USD only. Adds thousand separators and always renders
1287
+ * two decimal places.
1288
+ *
1289
+ * @param {number} minor — value in minor units (cents).
1290
+ * @param {string} currency — currency code (only "USD" supported in v2).
1291
+ * @returns {string}
1292
+ * @throws {Error} on unsupported currency.
1293
+ */
1294
+ export function formatMoneyValue(minor, currency) {
1295
+ if (currency !== "USD") {
1296
+ throw new Error(`only USD is supported in v0.3.0; got currency="${currency}"`);
1297
+ }
1298
+ const sign = minor < 0 ? "-" : "";
1299
+ const abs = Math.abs(minor);
1300
+ const dollars = Math.floor(abs / 100);
1301
+ const cents = abs % 100;
1302
+ const intPart = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1303
+ return `${sign}$${intPart}.${String(cents).padStart(2, "0")}`;
1304
+ }
1305
+
1306
+ /**
1307
+ * Normalize a raw value per a placeholder's schema-declared type. Returns
1308
+ * the normalized string. Throws on invalid input (Q3.3 → hard error).
1309
+ * If no `type` is declared on the placeholder, returns the raw value
1310
+ * unchanged.
1311
+ *
1312
+ * @param {string} rawValue
1313
+ * @param {{ type?: string|null, format?: string|null, currency?: string|null, key?: string }} placeholder
1314
+ * @returns {string}
1315
+ * @throws {Error} with `.exitCode = EXIT.VALIDATION` on bad input.
1316
+ */
1317
+ export function normalizeTypedValue(rawValue, placeholder) {
1318
+ const type = placeholder && placeholder.type;
1319
+ if (!type) return rawValue;
1320
+ if (type === "date") {
1321
+ const date = parseDateValue(rawValue);
1322
+ if (!date) {
1323
+ const e = new Error(
1324
+ `could not parse "${rawValue}" as a date. expected ISO ` +
1325
+ `(2027-01-15) or spelled ("January 15, 2027"). ` +
1326
+ `US ("01/15/2027") and European ("15/01/2027") forms are not ` +
1327
+ `accepted — they're ambiguous.`
1328
+ );
1329
+ e.exitCode = EXIT.VALIDATION;
1330
+ throw e;
1331
+ }
1332
+ return formatDateValue(date, placeholder.format || "MMMM d, yyyy");
1333
+ }
1334
+ if (type === "money") {
1335
+ const minor = parseMoneyValue(rawValue);
1336
+ if (minor === null) {
1337
+ const e = new Error(
1338
+ `could not parse "${rawValue}" as money. expected like ` +
1339
+ `"$5,000", "5000.50", "$5M", "2.5K".`
1340
+ );
1341
+ e.exitCode = EXIT.VALIDATION;
1342
+ throw e;
1343
+ }
1344
+ return formatMoneyValue(minor, placeholder.currency || "USD");
1345
+ }
1346
+ if (type === "party") {
1347
+ const s = String(rawValue).trim();
1348
+ if (!s) {
1349
+ const e = new Error(`party value must be non-empty`);
1350
+ e.exitCode = EXIT.VALIDATION;
1351
+ throw e;
1352
+ }
1353
+ if (/\]\(/.test(s)) {
1354
+ const e = new Error(`party value "${rawValue}" contains a markdown link; pass the bare party name instead.`);
1355
+ e.exitCode = EXIT.VALIDATION;
1356
+ throw e;
1357
+ }
1358
+ if (/[.!?,;:]$/.test(s)) {
1359
+ const e = new Error(`party value "${rawValue}" has trailing punctuation; remove it before passing.`);
1360
+ e.exitCode = EXIT.VALIDATION;
1361
+ throw e;
1362
+ }
1363
+ return s;
1364
+ }
1365
+ const e = new Error(`unknown type "${type}" on placeholder${placeholder.key ? ` "${placeholder.key}"` : ""}. expected one of: date, money, party.`);
1366
+ e.exitCode = EXIT.IO;
1367
+ throw e;
1368
+ }
1369
+
1370
+ /**
1371
+ * Run {@link normalizeTypedValue} across every resolved placeholder value.
1372
+ * Mutates `resolved` in place with normalized strings. Collects all errors
1373
+ * before returning so the user sees every type failure at once.
1374
+ *
1375
+ * @param {Placeholder[]} placeholders
1376
+ * @param {Object<string,string>} resolved
1377
+ * @returns {{ ok: boolean, errors: Array<{ key: string, message: string }>, normalized: Object<string,{from: string, to: string, type: string}> }}
1378
+ */
1379
+ export function normalizeTypedValues(placeholders, resolved) {
1380
+ const errors = [];
1381
+ const normalized = {};
1382
+ for (const p of placeholders) {
1383
+ if (!p.type) continue;
1384
+ if (resolved[p.key] === undefined) continue;
1385
+ const raw = resolved[p.key];
1386
+ try {
1387
+ const norm = normalizeTypedValue(raw, p);
1388
+ if (norm !== raw) normalized[p.key] = { from: raw, to: norm, type: p.type };
1389
+ resolved[p.key] = norm;
1390
+ } catch (e) {
1391
+ errors.push({ key: p.key, message: e.message });
1392
+ }
1393
+ }
1394
+ return { ok: errors.length === 0, errors, normalized };
1395
+ }
1396
+
1168
1397
  async function nodePrompter(placeholder) {
1169
1398
  if (!process.stdin.isTTY) return null;
1170
1399
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -1403,6 +1632,21 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1403
1632
  }
1404
1633
  return EXIT.VALIDATION;
1405
1634
  }
1635
+ // v2 #3: typed-parameter validation. Mirror what cmdDraft does so
1636
+ // `--validate` catches type errors before the user runs draft.
1637
+ const typeCheck = normalizeTypedValues(result.placeholders, resolved);
1638
+ if (!typeCheck.ok) {
1639
+ for (const te of typeCheck.errors) {
1640
+ err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
1641
+ }
1642
+ if (opts.json) {
1643
+ out.write(JSON.stringify({
1644
+ ok: false,
1645
+ type_errors: typeCheck.errors.map(({ key, message }) => ({ key, message })),
1646
+ }, null, 2) + "\n");
1647
+ }
1648
+ return EXIT.VALIDATION;
1649
+ }
1406
1650
  if (opts.json) {
1407
1651
  out.write(JSON.stringify({ ok: true, resolved: Object.keys(resolved), sources }, null, 2) + "\n");
1408
1652
  } else {
@@ -1472,6 +1716,17 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1472
1716
  return EXIT.VALIDATION;
1473
1717
  }
1474
1718
 
1719
+ // v2 #3: typed-parameter normalization. Schema entries can declare
1720
+ // `type: date | money | party`. Inputs are validated and normalized
1721
+ // before substitution. Hard error on bad input (Q3.3 decision).
1722
+ const typeCheck = normalizeTypedValues(result.placeholders, resolved);
1723
+ if (!typeCheck.ok) {
1724
+ for (const te of typeCheck.errors) {
1725
+ err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
1726
+ }
1727
+ return EXIT.VALIDATION;
1728
+ }
1729
+
1475
1730
  // Diff mode: print a substitution table and exit without writing output.
1476
1731
  if (opts.diff) {
1477
1732
  if (opts.json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drbaher/draft-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "description": "Fill placeholders in a legal-document template with parameter values. Part of the contract-operations suite.",
5
5
  "type": "module",
6
6
  "bin": {