@drbaher/draft-cli 0.5.0 → 0.7.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/CHANGELOG.md +95 -0
- package/PARAM_SCHEMA.md +127 -0
- package/draft-cli.mjs +384 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,101 @@ 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.7.0 — 2026-05-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Multi-document bundles.** `draft --bundle <bundle.json>` reads a
|
|
12
|
+
bundle definition and fills multiple templates with the same set of
|
|
13
|
+
parameter values in one invocation:
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"_meta": { "schema_version": 1 },
|
|
17
|
+
"outputs": [
|
|
18
|
+
{ "template": "msa/v3.md", "output": "out/msa.md" },
|
|
19
|
+
{ "template": "order-form/v3.md", "output": "out/order-form.md" }
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
Each template runs through detection independently. Placeholders
|
|
24
|
+
across templates are unioned by key (so a key declared in any
|
|
25
|
+
template's schema applies to all — Q3.3 locked). Resolution,
|
|
26
|
+
typed-parameter normalization, and computed values all run once on
|
|
27
|
+
the union. Each output is then substituted using its own
|
|
28
|
+
template/tier and written to its own path. `parties.json` refs
|
|
29
|
+
(v0.6.0) resolve inside bundle entries too.
|
|
30
|
+
- **Schema-union semantics.** A key declared/detected in any bundle
|
|
31
|
+
template applies to every template in the bundle. First-occurrence
|
|
32
|
+
metadata wins; resolved values flow to all templates that reference
|
|
33
|
+
the same key.
|
|
34
|
+
- **`.docx` bundle entries** round-trip through `substituteDocxXml`
|
|
35
|
+
when the entry's `output` path has the `.docx` extension. Same
|
|
36
|
+
runs/styles preservation as single-doc `.docx` mode.
|
|
37
|
+
- **New public API:** `loadBundle(path)`, `cmdBundle(opts, bundle,
|
|
38
|
+
paramsObj, envObj, io)`.
|
|
39
|
+
|
|
40
|
+
### Decisions locked (V2_BRIEFS_REMAINING Q3.1–Q3.3)
|
|
41
|
+
|
|
42
|
+
- **Q3.1 Bundle file format:** JSON object with `outputs` array of
|
|
43
|
+
`{template, output}` pairs. Each entry has its own output path,
|
|
44
|
+
enabling per-doc overrides without inventing a custom DSL.
|
|
45
|
+
- **Q3.2 Partial-failure policy:** abort-all. Any pre-write error
|
|
46
|
+
(no detection in an entry, missing required param across the
|
|
47
|
+
union, type / computed / ref failure, positional mismatch, schema
|
|
48
|
+
orphan) exits 4 before any file is written. Write failures
|
|
49
|
+
mid-bundle exit 1; earlier successful writes are not rolled back
|
|
50
|
+
(best-effort atomicity at the filesystem boundary).
|
|
51
|
+
- **Q3.3 Schema union semantics:** keys declared in any template's
|
|
52
|
+
schema (or detected as canonical-key matches without a schema)
|
|
53
|
+
apply across the bundle. Same value resolves into every template
|
|
54
|
+
that references the key.
|
|
55
|
+
|
|
56
|
+
## 0.6.0 — 2026-05-16
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **Cross-template `parties.json` registry.** A repo-local
|
|
61
|
+
`parties.json` declares known parties once; templates' schemas
|
|
62
|
+
reference fields with `ref:parties.<key>.<field>`:
|
|
63
|
+
```json
|
|
64
|
+
// parties.json
|
|
65
|
+
{ "acme_corp": { "name": "Acme Corporation", "state": "Delaware" } }
|
|
66
|
+
```
|
|
67
|
+
```json
|
|
68
|
+
// <template>.params.json
|
|
69
|
+
{
|
|
70
|
+
"_meta": { "v": 1 },
|
|
71
|
+
"party_a": { "aliases": ["Party A"], "default": "ref:parties.acme_corp.name" },
|
|
72
|
+
"party_a_state": { "aliases": ["Party A State"], "default": "ref:parties.acme_corp.state" }
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
Resolution happens between `resolveValues` and typed-parameter
|
|
76
|
+
normalization, so `ref:`-resolved values feed cleanly into
|
|
77
|
+
`type: date | money | party` flows.
|
|
78
|
+
- **`--parties PATH` flag** overrides the default CWD/`parties.json`
|
|
79
|
+
lookup. Missing explicit path → exit 1 with a clear error.
|
|
80
|
+
- **New public API:** `loadParties(path)`, `resolveRef(value, parties)`,
|
|
81
|
+
`resolveRefs(resolved, sources, parties)`.
|
|
82
|
+
|
|
83
|
+
### Decisions locked (V2_BRIEFS_REMAINING Q2.1–Q2.3)
|
|
84
|
+
|
|
85
|
+
- **Q2.1 File location:** default is `./parties.json` in CWD;
|
|
86
|
+
override with `--parties PATH`.
|
|
87
|
+
- **Q2.2 Ref scope:** refs resolve in `--params` JSON and schema
|
|
88
|
+
`default` values only. CLI flag values with `ref:` prefix pass
|
|
89
|
+
through **unchanged** — they're treated as literal strings.
|
|
90
|
+
- **Q2.3 Versioning:** out of scope for v0.6.0. When a party's
|
|
91
|
+
metadata changes in `parties.json`, all drafts that ref it produce
|
|
92
|
+
different output if re-run. Documented as a known property.
|
|
93
|
+
|
|
94
|
+
### Schema-contract change
|
|
95
|
+
|
|
96
|
+
`PARAM_SCHEMA.md` §5 gains a "Cross-template `parties.json` registry"
|
|
97
|
+
subsection. v0.6.0 schemas are forward-compatible with v0.5.x
|
|
98
|
+
readers — `ref:` strings just look like literal values to older
|
|
99
|
+
readers (and won't substitute correctly, but won't error out either
|
|
100
|
+
since "ref:..." is a valid string).
|
|
101
|
+
|
|
7
102
|
## 0.5.0 — 2026-05-16
|
|
8
103
|
|
|
9
104
|
### Added
|
package/PARAM_SCHEMA.md
CHANGED
|
@@ -388,6 +388,133 @@ and `position_index` (0-based) fields. `substitute` switches to
|
|
|
388
388
|
byte-index substitution for these, which `substituteDocxXml` does not
|
|
389
389
|
currently support.
|
|
390
390
|
|
|
391
|
+
### Cross-template `parties.json` registry (v0.6.0, opt-in)
|
|
392
|
+
|
|
393
|
+
A repo-local `parties.json` declares known parties once; templates'
|
|
394
|
+
schemas reference fields with `ref:parties.<party_key>.<field>`.
|
|
395
|
+
Eliminates duplicating party metadata (name, state, CIK, signing
|
|
396
|
+
contact) across every template.
|
|
397
|
+
|
|
398
|
+
```json
|
|
399
|
+
// parties.json
|
|
400
|
+
{
|
|
401
|
+
"acme_corp": {
|
|
402
|
+
"name": "Acme Corporation",
|
|
403
|
+
"state": "Delaware",
|
|
404
|
+
"cik": "0001234567"
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
```json
|
|
410
|
+
// <template>.params.json
|
|
411
|
+
{
|
|
412
|
+
"_meta": { "schema_version": 1 },
|
|
413
|
+
"party_a": { "aliases": ["Party A"], "default": "ref:parties.acme_corp.name" },
|
|
414
|
+
"party_a_state": { "aliases": ["Party A State"], "default": "ref:parties.acme_corp.state" }
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Q2.1 locked:** default file location is `./parties.json` in the
|
|
419
|
+
process CWD. Override with `--parties PATH`. Missing explicit path
|
|
420
|
+
is exit 1 (`EXIT.IO`); missing default file silently means "no
|
|
421
|
+
registry loaded" (refs then fail at resolution time with a clear hint).
|
|
422
|
+
|
|
423
|
+
**Q2.2 locked:** refs resolve in `--params` JSON values and schema
|
|
424
|
+
`default` values only. **CLI flag values pass through unchanged** —
|
|
425
|
+
`--party-a "ref:parties.acme.name"` is treated as a literal string
|
|
426
|
+
that happens to start with `ref:`. This keeps CLI parsing
|
|
427
|
+
unambiguous and avoids users accidentally leaking parties data on
|
|
428
|
+
the command line.
|
|
429
|
+
|
|
430
|
+
**Q2.3 locked:** versioning is out of scope for v0.6.0. When a
|
|
431
|
+
party's metadata changes in `parties.json`, all drafts that ref it
|
|
432
|
+
produce different output if re-run. This is by design (single source
|
|
433
|
+
of truth for party info), but worth knowing — historical drafts may
|
|
434
|
+
diverge from their original `parties.json` values.
|
|
435
|
+
|
|
436
|
+
**Ref syntax:** `ref:parties.<party_key>.<field>` where both
|
|
437
|
+
`<party_key>` and `<field>` match `[A-Za-z_][A-Za-z0-9_]*`. Malformed
|
|
438
|
+
refs, unknown party keys, and unknown fields all surface as hard
|
|
439
|
+
errors before substitution (exit 4).
|
|
440
|
+
|
|
441
|
+
**Resolution order:** value resolution → ref expansion → typed-param
|
|
442
|
+
normalization → computed values → substitute. Refs run before typed
|
|
443
|
+
normalization so a ref returning `"2027-01-15"` can still flow
|
|
444
|
+
through `type: date` formatting.
|
|
445
|
+
|
|
446
|
+
**Field-value coercion:** ref'd fields are coerced to strings via
|
|
447
|
+
`String(value)` (e.g. `cik: 1234567` becomes `"1234567"`). The
|
|
448
|
+
parties registry can store non-string values for ergonomics, but
|
|
449
|
+
substitution always uses string output.
|
|
450
|
+
|
|
451
|
+
Programmatic API: `loadParties(path)`, `resolveRef(value, parties)`,
|
|
452
|
+
`resolveRefs(resolved, sources, parties)`.
|
|
453
|
+
|
|
454
|
+
### Multi-document bundles (v0.7.0, opt-in)
|
|
455
|
+
|
|
456
|
+
`draft --bundle <bundle.json>` reads a bundle definition and fills
|
|
457
|
+
multiple templates with one shared set of parameter values:
|
|
458
|
+
|
|
459
|
+
```json
|
|
460
|
+
{
|
|
461
|
+
"_meta": { "schema_version": 1 },
|
|
462
|
+
"outputs": [
|
|
463
|
+
{ "template": "msa/v3.md", "output": "out/msa.md" },
|
|
464
|
+
{ "template": "order-form/v3.md", "output": "out/order-form.md" },
|
|
465
|
+
{ "template": "dpa/v2.docx", "output": "out/dpa.docx" }
|
|
466
|
+
]
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
```sh
|
|
471
|
+
draft --bundle deal.bundle.json --params deal.json --parties parties.json
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**Q3.1 locked:** the bundle file is a JSON object with an `outputs`
|
|
475
|
+
array of `{template, output}` pairs. Each entry has its own
|
|
476
|
+
`template` (filesystem path or `template-vault get` ref) and own
|
|
477
|
+
`output` path. No alternative shorter DSL — JSON is unambiguous and
|
|
478
|
+
extensible.
|
|
479
|
+
|
|
480
|
+
**Q3.2 locked:** abort-all. Any pre-write error (no detection in an
|
|
481
|
+
entry, missing required param across the union, type validation
|
|
482
|
+
failure, computed-value failure, ref-resolution failure, positional
|
|
483
|
+
mismatch, schema orphan) returns exit 4 **before any file is
|
|
484
|
+
written**. The bundle either writes all `outputs` or writes none.
|
|
485
|
+
Filesystem write errors mid-bundle exit 1; earlier successful
|
|
486
|
+
writes are not rolled back (best-effort atomicity at the filesystem
|
|
487
|
+
boundary).
|
|
488
|
+
|
|
489
|
+
**Q3.3 locked:** schema union. A key declared in any template's
|
|
490
|
+
schema, or detected as a canonical-key match without a schema,
|
|
491
|
+
applies across the entire bundle. The same resolved value flows to
|
|
492
|
+
every template that references the key. First-occurrence metadata
|
|
493
|
+
wins (`type`, `format`, `currency`, `computed`, `positions`, etc.);
|
|
494
|
+
templates with richer aliases for the same key contribute their
|
|
495
|
+
aliases for detection in their own body but don't redefine the key.
|
|
496
|
+
|
|
497
|
+
**Per-template detection independence:** each bundle entry runs the
|
|
498
|
+
full T1–T5 cascade against its own body. Different entries can land
|
|
499
|
+
on different tiers (e.g. MSA on T1 brackets, DPA on T3 highlights).
|
|
500
|
+
Positional addressing on T1/T2 still works per entry.
|
|
501
|
+
|
|
502
|
+
**`.docx` entries** with a `.docx` output path round-trip via
|
|
503
|
+
`substituteDocxXml` + `writeDocxBuffer`, preserving runs/styles.
|
|
504
|
+
Mixing text and `.docx` entries in the same bundle works.
|
|
505
|
+
|
|
506
|
+
**`parties.json` refs** (v0.6.0) resolve inside bundles too — load
|
|
507
|
+
the same parties file once via `--parties PATH` (or the CWD
|
|
508
|
+
default), and ref strings in any bundle template's schema default
|
|
509
|
+
or in shared `--params` expand against it.
|
|
510
|
+
|
|
511
|
+
**`--json`** for bundles emits a structured result listing each
|
|
512
|
+
entry's template, output path, and tier, plus the union of resolved
|
|
513
|
+
keys and their sources.
|
|
514
|
+
|
|
515
|
+
Programmatic API: `loadBundle(path)` parses + validates; `cmdBundle`
|
|
516
|
+
runs the orchestration with the same IO contract as `cmdDraft`.
|
|
517
|
+
|
|
391
518
|
### Orphan handling (Q4 locked)
|
|
392
519
|
|
|
393
520
|
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.
|
|
73
|
+
export const VERSION = "0.7.0";
|
|
74
74
|
|
|
75
75
|
// ─── EXIT CODES ─────────────────────────────────────────────────────────────
|
|
76
76
|
/**
|
|
@@ -277,6 +277,8 @@ export function parseArgs(argv) {
|
|
|
277
277
|
if (a === "--silent" || a === "-q") { opts.silent = true; continue; }
|
|
278
278
|
if (a === "--diff") { opts.diff = true; continue; }
|
|
279
279
|
if (a === "--params") { opts.params = argv[++i]; continue; }
|
|
280
|
+
if (a === "--parties") { opts.parties = argv[++i]; continue; }
|
|
281
|
+
if (a === "--bundle") { opts.bundle = argv[++i]; continue; }
|
|
280
282
|
if (a === "--output" || a === "-o") { opts.output = argv[++i]; continue; }
|
|
281
283
|
if (a === "--syntax") {
|
|
282
284
|
const v = argv[++i];
|
|
@@ -1272,6 +1274,181 @@ export function loadParamsFile(path) {
|
|
|
1272
1274
|
}
|
|
1273
1275
|
}
|
|
1274
1276
|
|
|
1277
|
+
/**
|
|
1278
|
+
* Load a `parties.json` registry (v2 #5). Returns the parsed object or
|
|
1279
|
+
* `null` if no file is present. Explicit `--parties PATH` errors if
|
|
1280
|
+
* the path doesn't exist; the default `./parties.json` is treated as
|
|
1281
|
+
* absent if the file isn't there (no error).
|
|
1282
|
+
*
|
|
1283
|
+
* @param {string | null} explicitPath — value of `--parties PATH`, or null
|
|
1284
|
+
* to auto-detect `./parties.json` in CWD.
|
|
1285
|
+
* @returns {Object<string, Object<string, *>> | null}
|
|
1286
|
+
* @throws {Error} with `.exitCode = EXIT.IO` on missing explicit file or invalid JSON.
|
|
1287
|
+
*/
|
|
1288
|
+
export function loadParties(explicitPath) {
|
|
1289
|
+
const fallback = "parties.json";
|
|
1290
|
+
const path = explicitPath || (existsSync(fallback) ? fallback : null);
|
|
1291
|
+
if (!path) return null;
|
|
1292
|
+
if (!existsSync(path)) {
|
|
1293
|
+
const e = new Error(`parties file not found: ${path}`);
|
|
1294
|
+
e.exitCode = EXIT.IO;
|
|
1295
|
+
throw e;
|
|
1296
|
+
}
|
|
1297
|
+
let parsed;
|
|
1298
|
+
try {
|
|
1299
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
const e = new Error(`could not parse ${path}: ${err.message}`);
|
|
1302
|
+
e.exitCode = EXIT.IO;
|
|
1303
|
+
throw e;
|
|
1304
|
+
}
|
|
1305
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1306
|
+
const e = new Error(`parties file ${path} must be a JSON object`);
|
|
1307
|
+
e.exitCode = EXIT.IO;
|
|
1308
|
+
throw e;
|
|
1309
|
+
}
|
|
1310
|
+
// Reject non-object party entries early so downstream `ref:` resolution
|
|
1311
|
+
// can safely lookup fields without a per-call shape check.
|
|
1312
|
+
for (const [partyKey, party] of Object.entries(parsed)) {
|
|
1313
|
+
if (!party || typeof party !== "object" || Array.isArray(party)) {
|
|
1314
|
+
const e = new Error(`parties file ${path}: entry "${partyKey}" must be a JSON object`);
|
|
1315
|
+
e.exitCode = EXIT.IO;
|
|
1316
|
+
throw e;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return parsed;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Resolve a `ref:parties.<party>.<field>` reference against a loaded
|
|
1324
|
+
* parties object. Throws on malformed ref, missing parties registry,
|
|
1325
|
+
* unknown party, or unknown field. Non-`ref:` strings pass through
|
|
1326
|
+
* unchanged.
|
|
1327
|
+
*
|
|
1328
|
+
* @param {string} value
|
|
1329
|
+
* @param {Object<string, Object<string, *>> | null} parties
|
|
1330
|
+
* @returns {string}
|
|
1331
|
+
* @throws {Error} on malformed or unresolvable reference.
|
|
1332
|
+
*/
|
|
1333
|
+
export function resolveRef(value, parties) {
|
|
1334
|
+
if (typeof value !== "string" || !value.startsWith("ref:")) return value;
|
|
1335
|
+
if (!parties) {
|
|
1336
|
+
throw new Error(`reference "${value}" but no parties.json loaded (pass --parties PATH or put parties.json in cwd)`);
|
|
1337
|
+
}
|
|
1338
|
+
const m = /^ref:parties\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
|
|
1339
|
+
if (!m) {
|
|
1340
|
+
throw new Error(`malformed reference "${value}" (expected "ref:parties.<party_key>.<field>")`);
|
|
1341
|
+
}
|
|
1342
|
+
const [, partyKey, fieldKey] = m;
|
|
1343
|
+
const party = parties[partyKey];
|
|
1344
|
+
if (!party) {
|
|
1345
|
+
throw new Error(`unknown party "${partyKey}" in reference "${value}"`);
|
|
1346
|
+
}
|
|
1347
|
+
if (!(fieldKey in party)) {
|
|
1348
|
+
throw new Error(`unknown field "${fieldKey}" on party "${partyKey}" in reference "${value}"`);
|
|
1349
|
+
}
|
|
1350
|
+
const out = party[fieldKey];
|
|
1351
|
+
return out == null ? "" : String(out);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Walk a resolved-values map and replace any `ref:` strings with their
|
|
1356
|
+
* resolved values from the parties registry. CLI-sourced values are
|
|
1357
|
+
* left alone (Q2.2: refs are params/default only, never CLI). Collects
|
|
1358
|
+
* all errors before returning so the user sees every failure at once.
|
|
1359
|
+
*
|
|
1360
|
+
* @param {Object<string,string>} resolved — mutated in place.
|
|
1361
|
+
* @param {Object<string,string>} sources — from `resolveValues`.
|
|
1362
|
+
* @param {Object<string, Object<string, *>> | null} parties
|
|
1363
|
+
* @returns {{ ok: boolean, errors: Array<{ key: string, message: string }> }}
|
|
1364
|
+
*/
|
|
1365
|
+
export function resolveRefs(resolved, sources, parties) {
|
|
1366
|
+
const errors = [];
|
|
1367
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
1368
|
+
if (sources[key] === "cli") continue; // Q2.2: CLI values pass through
|
|
1369
|
+
if (typeof value !== "string" || !value.startsWith("ref:")) continue;
|
|
1370
|
+
try {
|
|
1371
|
+
resolved[key] = resolveRef(value, parties);
|
|
1372
|
+
} catch (e) {
|
|
1373
|
+
errors.push({ key, message: e.message });
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return { ok: errors.length === 0, errors };
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Load and validate a bundle definition (v2 #6). Bundles describe
|
|
1381
|
+
* multiple templates that should be filled with the same set of
|
|
1382
|
+
* parameter values in one invocation:
|
|
1383
|
+
*
|
|
1384
|
+
* {
|
|
1385
|
+
* "_meta": { "schema_version": 1 },
|
|
1386
|
+
* "outputs": [
|
|
1387
|
+
* { "template": "msa/v3.md", "output": "out/msa.md" },
|
|
1388
|
+
* { "template": "order-form/v3.md", "output": "out/order-form.md" }
|
|
1389
|
+
* ]
|
|
1390
|
+
* }
|
|
1391
|
+
*
|
|
1392
|
+
* Returns the parsed bundle. Throws on missing file, invalid JSON, no
|
|
1393
|
+
* `outputs` array, empty `outputs`, missing `template`/`output` on an
|
|
1394
|
+
* entry, or duplicate output paths.
|
|
1395
|
+
*
|
|
1396
|
+
* @param {string} path
|
|
1397
|
+
* @returns {{ outputs: Array<{ template: string, output: string }> }}
|
|
1398
|
+
* @throws {Error} with `.exitCode = EXIT.IO`
|
|
1399
|
+
*/
|
|
1400
|
+
export function loadBundle(path) {
|
|
1401
|
+
if (!existsSync(path)) {
|
|
1402
|
+
const e = new Error(`bundle file not found: ${path}`);
|
|
1403
|
+
e.exitCode = EXIT.IO;
|
|
1404
|
+
throw e;
|
|
1405
|
+
}
|
|
1406
|
+
let parsed;
|
|
1407
|
+
try {
|
|
1408
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
const e = new Error(`could not parse bundle ${path}: ${err.message}`);
|
|
1411
|
+
e.exitCode = EXIT.IO;
|
|
1412
|
+
throw e;
|
|
1413
|
+
}
|
|
1414
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1415
|
+
const e = new Error(`bundle ${path} must be a JSON object`);
|
|
1416
|
+
e.exitCode = EXIT.IO;
|
|
1417
|
+
throw e;
|
|
1418
|
+
}
|
|
1419
|
+
if (!Array.isArray(parsed.outputs) || parsed.outputs.length === 0) {
|
|
1420
|
+
const e = new Error(`bundle ${path}: missing or empty "outputs" array`);
|
|
1421
|
+
e.exitCode = EXIT.IO;
|
|
1422
|
+
throw e;
|
|
1423
|
+
}
|
|
1424
|
+
const seenOutputs = new Set();
|
|
1425
|
+
for (let i = 0; i < parsed.outputs.length; i++) {
|
|
1426
|
+
const o = parsed.outputs[i];
|
|
1427
|
+
if (!o || typeof o !== "object" || Array.isArray(o)) {
|
|
1428
|
+
const e = new Error(`bundle ${path}: outputs[${i}] must be an object`);
|
|
1429
|
+
e.exitCode = EXIT.IO;
|
|
1430
|
+
throw e;
|
|
1431
|
+
}
|
|
1432
|
+
if (typeof o.template !== "string" || !o.template) {
|
|
1433
|
+
const e = new Error(`bundle ${path}: outputs[${i}].template must be a non-empty string`);
|
|
1434
|
+
e.exitCode = EXIT.IO;
|
|
1435
|
+
throw e;
|
|
1436
|
+
}
|
|
1437
|
+
if (typeof o.output !== "string" || !o.output) {
|
|
1438
|
+
const e = new Error(`bundle ${path}: outputs[${i}].output must be a non-empty string`);
|
|
1439
|
+
e.exitCode = EXIT.IO;
|
|
1440
|
+
throw e;
|
|
1441
|
+
}
|
|
1442
|
+
if (seenOutputs.has(o.output)) {
|
|
1443
|
+
const e = new Error(`bundle ${path}: outputs[${i}].output "${o.output}" is duplicated`);
|
|
1444
|
+
e.exitCode = EXIT.IO;
|
|
1445
|
+
throw e;
|
|
1446
|
+
}
|
|
1447
|
+
seenOutputs.add(o.output);
|
|
1448
|
+
}
|
|
1449
|
+
return { outputs: parsed.outputs.map(o => ({ template: o.template, output: o.output })) };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1275
1452
|
/**
|
|
1276
1453
|
* Resolve a value for every placeholder using the locked precedence chain:
|
|
1277
1454
|
* CLI flag > `--params` JSON > `--interactive` prompt > schema default >
|
|
@@ -1912,7 +2089,7 @@ export async function cmdListPlaceholders(opts, input, schema, envObj, { fetcher
|
|
|
1912
2089
|
return EXIT.OK;
|
|
1913
2090
|
}
|
|
1914
2091
|
|
|
1915
|
-
export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
|
|
2092
|
+
export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
|
|
1916
2093
|
const result = await runCascade(input, opts, schema, envObj, { fetcher });
|
|
1917
2094
|
if (result.tier === "none") {
|
|
1918
2095
|
err.write(paint("error: no placeholders detected by any tier\n", "red", err));
|
|
@@ -1940,6 +2117,22 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
|
|
|
1940
2117
|
}
|
|
1941
2118
|
return EXIT.VALIDATION;
|
|
1942
2119
|
}
|
|
2120
|
+
// v2 #5: parties.json ref resolution. Refs like
|
|
2121
|
+
// `ref:parties.acme_corp.name` in --params or schema defaults expand
|
|
2122
|
+
// before typed normalization. CLI values pass through unchanged.
|
|
2123
|
+
const refCheck = resolveRefs(resolved, sources, parties);
|
|
2124
|
+
if (!refCheck.ok) {
|
|
2125
|
+
for (const re of refCheck.errors) {
|
|
2126
|
+
err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
|
|
2127
|
+
}
|
|
2128
|
+
if (opts.json) {
|
|
2129
|
+
out.write(JSON.stringify({
|
|
2130
|
+
ok: false,
|
|
2131
|
+
ref_errors: refCheck.errors.map(({ key, message }) => ({ key, message })),
|
|
2132
|
+
}, null, 2) + "\n");
|
|
2133
|
+
}
|
|
2134
|
+
return EXIT.VALIDATION;
|
|
2135
|
+
}
|
|
1943
2136
|
// v2 #3: typed-parameter validation. Mirror what cmdDraft does so
|
|
1944
2137
|
// `--validate` catches type errors before the user runs draft.
|
|
1945
2138
|
const typeCheck = normalizeTypedValues(result.placeholders, resolved);
|
|
@@ -1977,7 +2170,7 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
|
|
|
1977
2170
|
return EXIT.OK;
|
|
1978
2171
|
}
|
|
1979
2172
|
|
|
1980
|
-
export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
|
|
2173
|
+
export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
|
|
1981
2174
|
const result = await runCascade(input, opts, schema, envObj, { fetcher });
|
|
1982
2175
|
if (result.tier === "none") {
|
|
1983
2176
|
const hasProvider = Boolean(llmProviderFromEnv(envObj));
|
|
@@ -2045,6 +2238,18 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
|
|
|
2045
2238
|
return EXIT.VALIDATION;
|
|
2046
2239
|
}
|
|
2047
2240
|
|
|
2241
|
+
// v2 #5: parties.json ref resolution. Refs like
|
|
2242
|
+
// `ref:parties.acme_corp.name` in --params or schema defaults expand
|
|
2243
|
+
// before typed normalization. CLI values pass through unchanged
|
|
2244
|
+
// (Q2.2 lock).
|
|
2245
|
+
const refCheck = resolveRefs(resolved, sources, parties);
|
|
2246
|
+
if (!refCheck.ok) {
|
|
2247
|
+
for (const re of refCheck.errors) {
|
|
2248
|
+
err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
|
|
2249
|
+
}
|
|
2250
|
+
return EXIT.VALIDATION;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2048
2253
|
// v2 #3: typed-parameter normalization. Schema entries can declare
|
|
2049
2254
|
// `type: date | money | party`. Inputs are validated and normalized
|
|
2050
2255
|
// before substitution. Hard error on bad input (Q3.3 decision).
|
|
@@ -2151,6 +2356,151 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
|
|
|
2151
2356
|
return EXIT.OK;
|
|
2152
2357
|
}
|
|
2153
2358
|
|
|
2359
|
+
/**
|
|
2360
|
+
* cmdBundle — orchestrate filling multiple templates with one shared
|
|
2361
|
+
* parameter set (v2 #6). For each bundle entry:
|
|
2362
|
+
* 1. resolveInput + loadSchema (per template)
|
|
2363
|
+
* 2. runCascade (per template)
|
|
2364
|
+
* 3. union placeholders by key
|
|
2365
|
+
* Then resolve values once across the union (CLI/--params/interactive/
|
|
2366
|
+
* default), run typed-param normalization + computed values, and write
|
|
2367
|
+
* each output. Q3.2 locked: any pre-write error (no-detection in an
|
|
2368
|
+
* entry, missing required param across the union, type / computed
|
|
2369
|
+
* failure) aborts the whole bundle before any file is written.
|
|
2370
|
+
*
|
|
2371
|
+
* @param {Object} opts
|
|
2372
|
+
* @param {{outputs: Array<{template: string, output: string}>}} bundle
|
|
2373
|
+
* @param {Object} paramsObj
|
|
2374
|
+
* @param {Object} envObj
|
|
2375
|
+
* @returns {Promise<number>} exit code
|
|
2376
|
+
*/
|
|
2377
|
+
export async function cmdBundle(opts, bundle, paramsObj, envObj, { fetcher, out, err, spawner, stdinReader, parties = null } = {}) {
|
|
2378
|
+
// Phase 1: load each template + schema, run detection.
|
|
2379
|
+
const entries = [];
|
|
2380
|
+
for (let i = 0; i < bundle.outputs.length; i++) {
|
|
2381
|
+
const o = bundle.outputs[i];
|
|
2382
|
+
let input, schema, cascade;
|
|
2383
|
+
try {
|
|
2384
|
+
input = await resolveInput(o.template, { spawner, stdinReader });
|
|
2385
|
+
schema = loadSchema(input.path);
|
|
2386
|
+
} catch (e) {
|
|
2387
|
+
err.write(paint(`error: bundle entry ${i} "${o.template}": ${e.message}\n`, "red", err));
|
|
2388
|
+
return e.exitCode || EXIT.IO;
|
|
2389
|
+
}
|
|
2390
|
+
cascade = await runCascade(input, opts, schema, envObj, { fetcher });
|
|
2391
|
+
if (cascade.tier === "none") {
|
|
2392
|
+
err.write(paint(`error: bundle entry ${i} "${o.template}": no placeholders detected by any tier\n`, "red", err));
|
|
2393
|
+
return EXIT.VALIDATION;
|
|
2394
|
+
}
|
|
2395
|
+
// v2 #7 positional errors per template — abort early.
|
|
2396
|
+
if (cascade.positional_errors && cascade.positional_errors.length > 0) {
|
|
2397
|
+
for (const pe of cascade.positional_errors) {
|
|
2398
|
+
err.write(paint(`error: bundle entry ${i} "${o.template}" positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
|
|
2399
|
+
}
|
|
2400
|
+
return EXIT.VALIDATION;
|
|
2401
|
+
}
|
|
2402
|
+
// Orphan check per template (schema declares something not detected here).
|
|
2403
|
+
const orphans = findOrphans(schema, cascade.placeholders, cascade.detected_schema_keys);
|
|
2404
|
+
if (orphans.length > 0) {
|
|
2405
|
+
for (const oo of orphans) {
|
|
2406
|
+
err.write(paint(`error: bundle entry ${i} "${o.template}" schema declares "${oo.key}" but no matching phrase was detected by tier '${cascade.tier}'.\n`, "red", err));
|
|
2407
|
+
}
|
|
2408
|
+
return EXIT.VALIDATION;
|
|
2409
|
+
}
|
|
2410
|
+
entries.push({ output: o.output, input, schema, cascade });
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// Phase 2: union placeholders by key. Q3.3 locked: union semantics —
|
|
2414
|
+
// a key declared/detected in any template applies to all. First
|
|
2415
|
+
// occurrence's metadata wins (required, default, type, format, etc.);
|
|
2416
|
+
// a per-template later occurrence may have richer aliases but we keep
|
|
2417
|
+
// the first canonical entry.
|
|
2418
|
+
const unionPlaceholders = [];
|
|
2419
|
+
const seenKeys = new Set();
|
|
2420
|
+
for (const e of entries) {
|
|
2421
|
+
for (const p of e.cascade.placeholders) {
|
|
2422
|
+
if (seenKeys.has(p.key)) continue;
|
|
2423
|
+
seenKeys.add(p.key);
|
|
2424
|
+
unionPlaceholders.push(p);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// Phase 3: shared value resolution + footgun guard.
|
|
2429
|
+
const { resolved, missing, sources } = await resolveValues(unionPlaceholders, opts, paramsObj);
|
|
2430
|
+
const declaredKeys = new Set(unionPlaceholders.map((p) => p.key));
|
|
2431
|
+
const unusedFlags = Object.keys(opts.paramFlags).filter((k) => !declaredKeys.has(k));
|
|
2432
|
+
for (const u of unusedFlags) {
|
|
2433
|
+
err.write(paint(`warning: flag --${u.replace(/_/g, "-")} did not match any placeholder in any bundle template (possible typo?)\n`, "yellow", err));
|
|
2434
|
+
}
|
|
2435
|
+
if (missing.length > 0) {
|
|
2436
|
+
printMissing(missing, err);
|
|
2437
|
+
return EXIT.VALIDATION;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// v2 #5: parties.json refs resolve across the union before typed
|
|
2441
|
+
// normalization (same order as cmdDraft / cmdValidate).
|
|
2442
|
+
const refCheck = resolveRefs(resolved, sources, parties);
|
|
2443
|
+
if (!refCheck.ok) {
|
|
2444
|
+
for (const re of refCheck.errors) {
|
|
2445
|
+
err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
|
|
2446
|
+
}
|
|
2447
|
+
return EXIT.VALIDATION;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// Phase 4: typed-parameter + computed pipelines (same as cmdDraft).
|
|
2451
|
+
const typeCheck = normalizeTypedValues(unionPlaceholders, resolved);
|
|
2452
|
+
if (!typeCheck.ok) {
|
|
2453
|
+
for (const te of typeCheck.errors) {
|
|
2454
|
+
err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
|
|
2455
|
+
}
|
|
2456
|
+
return EXIT.VALIDATION;
|
|
2457
|
+
}
|
|
2458
|
+
const computeCheck = computeValues(unionPlaceholders, resolved);
|
|
2459
|
+
if (!computeCheck.ok) {
|
|
2460
|
+
for (const ce of computeCheck.errors) {
|
|
2461
|
+
err.write(paint(`error: computed value failed for "${ce.key}": ${ce.message}\n`, "red", err));
|
|
2462
|
+
}
|
|
2463
|
+
return EXIT.VALIDATION;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// Phase 5: substitute per template + write. Q3.2: any write failure
|
|
2467
|
+
// exits with EXIT.IO; earlier successful writes are NOT rolled back
|
|
2468
|
+
// (atomicity at the filesystem is best-effort).
|
|
2469
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2470
|
+
const e = entries[i];
|
|
2471
|
+
const outputText = substitute(e.input.body, e.cascade.placeholders, resolved, e.cascade.tier);
|
|
2472
|
+
try {
|
|
2473
|
+
// For .docx input with .docx output: round-trip via substituteDocxXml.
|
|
2474
|
+
if (e.input.kind === "docx" && extname(e.output) === ".docx") {
|
|
2475
|
+
const { xml: newXml, warnings: dw } = substituteDocxXml(
|
|
2476
|
+
e.input.docxXml, e.cascade.placeholders, resolved, e.cascade.tier
|
|
2477
|
+
);
|
|
2478
|
+
if (dw.length) for (const w of dw) err.write(paint(`warning (entry ${i}): ${w}\n`, "yellow", err));
|
|
2479
|
+
const buf = await writeDocxBuffer(e.input.path, newXml);
|
|
2480
|
+
writeFileSync(e.output, buf);
|
|
2481
|
+
} else {
|
|
2482
|
+
writeFileSync(e.output, outputText, "utf8");
|
|
2483
|
+
}
|
|
2484
|
+
} catch (writeErr) {
|
|
2485
|
+
err.write(paint(`error: bundle entry ${i} could not write ${e.output}: ${writeErr.message}\n`, "red", err));
|
|
2486
|
+
return EXIT.IO;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
if (!opts.silent && !opts.json) {
|
|
2491
|
+
err.write(paint(`ok: wrote ${entries.length} document(s) — ${entries.map(e => e.output).join(", ")}\n`, "green", err));
|
|
2492
|
+
}
|
|
2493
|
+
if (opts.json) {
|
|
2494
|
+
out.write(JSON.stringify({
|
|
2495
|
+
ok: true,
|
|
2496
|
+
outputs: entries.map(e => ({ template: e.input.path, output: e.output, tier: e.cascade.tier })),
|
|
2497
|
+
resolved_keys: Object.keys(resolved),
|
|
2498
|
+
sources,
|
|
2499
|
+
}, null, 2) + "\n");
|
|
2500
|
+
}
|
|
2501
|
+
return EXIT.OK;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2154
2504
|
function describeInput(input) {
|
|
2155
2505
|
if (input.path) return input.path;
|
|
2156
2506
|
if (input.kind === "text") return "stdin";
|
|
@@ -2452,6 +2802,33 @@ export async function main(argv, io = {}) {
|
|
|
2452
2802
|
return await runCheckLlm(envObj, out, err, { fetcher });
|
|
2453
2803
|
}
|
|
2454
2804
|
|
|
2805
|
+
// v2 #6: bundle mode. `--bundle PATH` reads a bundle definition and
|
|
2806
|
+
// orchestrates filling each entry's template with shared parameters.
|
|
2807
|
+
// In bundle mode, no positional template arg is required (the bundle
|
|
2808
|
+
// declares them).
|
|
2809
|
+
if (opts.bundle) {
|
|
2810
|
+
if (opts.positional.length > 0) {
|
|
2811
|
+
err.write(paint(`error: --bundle does not take a positional template arg (the bundle declares them)\n`, "red", err));
|
|
2812
|
+
return EXIT.IO;
|
|
2813
|
+
}
|
|
2814
|
+
let bundle, paramsObj, envObj, parties;
|
|
2815
|
+
try {
|
|
2816
|
+
bundle = loadBundle(opts.bundle);
|
|
2817
|
+
paramsObj = loadParamsFile(opts.params);
|
|
2818
|
+
envObj = effectiveEnv(cwd, processEnv);
|
|
2819
|
+
parties = loadParties(opts.parties || null);
|
|
2820
|
+
} catch (e) {
|
|
2821
|
+
err.write(paint(`error: ${e.message}\n`, "red", err));
|
|
2822
|
+
return e.exitCode || EXIT.IO;
|
|
2823
|
+
}
|
|
2824
|
+
try {
|
|
2825
|
+
return await cmdBundle(opts, bundle, paramsObj, envObj, { fetcher, out, err, spawner, stdinReader, parties });
|
|
2826
|
+
} catch (e) {
|
|
2827
|
+
err.write(paint(`error: ${e.message}\n`, "red", err));
|
|
2828
|
+
return e.exitCode || EXIT.IO;
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2455
2832
|
if (opts.positional.length === 0) {
|
|
2456
2833
|
err.write(paint(`error: no template given\n`, "red", err));
|
|
2457
2834
|
err.write(`run \`draft --help\` for usage.\n`);
|
|
@@ -2462,12 +2839,13 @@ export async function main(argv, io = {}) {
|
|
|
2462
2839
|
return EXIT.IO;
|
|
2463
2840
|
}
|
|
2464
2841
|
|
|
2465
|
-
let input, schema, paramsObj, envObj;
|
|
2842
|
+
let input, schema, paramsObj, envObj, parties;
|
|
2466
2843
|
try {
|
|
2467
2844
|
input = await resolveInput(opts.positional[0], { spawner, stdinReader });
|
|
2468
2845
|
schema = loadSchema(input.path);
|
|
2469
2846
|
paramsObj = loadParamsFile(opts.params);
|
|
2470
2847
|
envObj = effectiveEnv(cwd, processEnv);
|
|
2848
|
+
parties = loadParties(opts.parties || null);
|
|
2471
2849
|
} catch (e) {
|
|
2472
2850
|
err.write(paint(`error: ${e.message}\n`, "red", err));
|
|
2473
2851
|
return e.exitCode || EXIT.IO;
|
|
@@ -2478,9 +2856,9 @@ export async function main(argv, io = {}) {
|
|
|
2478
2856
|
return await cmdListPlaceholders(opts, input, schema, envObj, { fetcher, out, err });
|
|
2479
2857
|
}
|
|
2480
2858
|
if (opts.validate) {
|
|
2481
|
-
return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
|
|
2859
|
+
return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
|
|
2482
2860
|
}
|
|
2483
|
-
return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
|
|
2861
|
+
return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
|
|
2484
2862
|
} catch (e) {
|
|
2485
2863
|
err.write(paint(`error: ${e.message}\n`, "red", err));
|
|
2486
2864
|
return e.exitCode || EXIT.IO;
|