Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ release.

## [Unreleased]

## [0.8.0] β€” 2026-06-21

SignWell field-placement fixes from field feedback, plus signing-order control,
a test-mode safety banner, and the field-coordinate contract.

### Added

- **`--ordered true|false`** on `request send`, `request run-email`, and `request send-embedded` β€” controls SignWell `apply_signing_order`. `false` requests parallel/unordered signing; default stays sequential when there are 2+ signers.
- **`bottomLeftToTopLeft()`** helper in `field-placement` + **[`docs/field-coordinates.md`](docs/field-coordinates.md)** β€” documents the per-provider `--field` coordinate contract (top-left origin; 1-based `page`/`signer` vs 0-based `doc`) and converts bottom-left/pdfjs detector coordinates to provider space.
- Loud **SignWell test-mode banner** on every send. Test mode stays the default (non-binding, watermarked) but is no longer silent: a prominent stderr banner fires whenever it's active, pointing at `--test-mode false`. Suppressed when test mode is off.

### Fixed

- **SignWell: custom `--field` placements were silently dropped.** `with_signature_page` was hardcoded `true`, which makes SignWell discard supplied fields and auto-place its own. It is now `false` whenever custom fields are present.
- **SignWell: fields were sent in the wrong place in the payload.** Fields were nested under `files[].fields`; SignWell requires a top-level 2-D `fields` array (one inner array per file). With both fixes, custom placements reach SignWell instead of being silently dropped (or rejected with `recipients.with_no_fields`). Adds a payload-level regression test.
- **Stray `ExperimentalWarning: SQLite` no longer clutters stderr.** `node:sqlite` is loaded lazily and the warning is filtered (set `SIGN_SHOW_WARNINGS=1` to restore it), keeping machine-readable output clean.
- **`sign mcp --help` now lists subcommands** (`mcp serve`, `mcp tools`) instead of erroring with "No help entry for mcp". Applies to any parent command.

### Changed

- `--field` help now documents `width`, `height`, the full `type:` set (`signature|initials|date|text|name|email`), the 0-based `doc` vs 1-based `page`/`signer` indexing, and points at `docs/field-coordinates.md`. Send commands document `--ordered` and `--test-mode`.

## [0.7.1] β€” 2026-06-03

Build-tooling only β€” **no runtime or API changes** from 0.7.0. Cut so the SEA
Expand Down
90 changes: 90 additions & 0 deletions docs/field-coordinates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Field placement & the coordinate contract

`--field` (on `request create` and `request run-email`) places a signature,
initials, date, or text box at an explicit spot on a document. This page is the
contract for what those coordinates mean β€” per provider β€” because the values are
passed through to the provider largely untransformed, and getting the origin
wrong lands a signature in the middle of your body text.

## The `--field` grammar

```
--field signer:N,doc:N,page:N,x:N,y:N[,type:T][,width:N][,height:N][,required:true|false]
```

| Key | Meaning | Indexing |
|------------|-------------------------------------------------------------------------|----------|
| `signer` | Which signer fills the field. Matches the `order:N` on `--signer`. | **1-based** |
| `doc` | Which document (when you pass multiple `--document`). | **0-based** |
| `page` | Page within that document. | **1-based** |
| `x`, `y` | Top-left corner of the field box, in provider units (see below). | β€” |
| `type` | `signature` (default) \| `initials` \| `date` \| `text` \| `name` \| `email`. | β€” |
| `width` | Field box width. Optional; providers apply a default if omitted. | β€” |
| `height` | Field box height. Optional; providers apply a default if omitted. | β€” |
| `required` | `true` (default) or `false`. | β€” |

> The mixed indexing is historical: `doc` is a 0-based array index, while `page`
> and `signer` mirror the 1-based numbers a human reads off the page and the
> `--signer order:N`. If you pass `doc:1` with a single document you'll get
> `Field doc:1 is out of range`.

Anchor / text-tag placement (`anchor:"Sign here"`) is **not** supported through
this CLI for any provider β€” you must supply explicit `page` + `x` + `y`.

## The origin: top-left, every provider

All three remote providers this CLI targets place fields from the **top-left
corner of the page**, with `x` increasing rightward and `y` increasing
**downward**:

| Provider | Origin | `x`/`y` refer to | Units |
|---------------|-----------|-------------------------|-----------------------------------------|
| SignWell | top-left | top-left of the field | page pixels (top-left origin) |
| Dropbox Sign | top-left | top-left of the field | pixels from the top-left of the page |
| DocuSign | top-left | top-left of the tab | pixels (`xPosition`/`yPosition`) |

So `--field ...,x:72,y:50` is "72 across, 50 down from the top-left corner".

## The trap: bottom-left detectors

PDF user space β€” and most pdfjs-based "find the signature line" detectors β€” use a
**bottom-left** origin, where `y` increases **upward**. If you feed those numbers
straight into `--field`, the field is mirrored vertically and lands in the wrong
place (often in the body text near the top).

Convert before you place. The flip is:

```
y_top_left = pageHeight - y_bottom_left - fieldHeight
```

This CLI ships that conversion so you don't hand-roll it:

```ts
import { bottomLeftToTopLeft } from "sign-cli/dist/lib/field-placement.js";

// pageHeight and y in the same units (e.g. PDF points; US Letter = 792pt tall)
const { x, y } = bottomLeftToTopLeft({ x: 72, y: 100, pageHeight: 792, height: 30 });
// β†’ { x: 72, y: 662 } ready for --field x:72,y:662,height:30
```

Pass `height` so the box's top edge lands where you expect; omit it and you get
the baseline point flipped (the field's top edge sits on the detected line and
the box extends downward).

### Units / DPI

`x`, `y`, `width`, `height` must all be in the **same** unit as the page
dimension you used for the conversion. If your detector reports PDF points
(72 per inch), keep everything in points. If it reports pixels rendered at some
DPI, convert the page height to that same pixel space first. Mixing points and
rendered pixels is the most common way to be "close but off by a scale factor".

## Quick checklist

- [ ] `signer` matches a `--signer order:N` (1-based).
- [ ] `doc` is 0-based; a single document is always `doc:0`.
- [ ] `x`/`y` are top-left origin, `y` increasing downward.
- [ ] If your coordinates came from a pdfjs/bottom-left detector, run them
through `bottomLeftToTopLeft` first.
- [ ] `x`/`y`/`width`/`height` are all in the same unit.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@drbaher/sign-cli",
"version": "0.7.1",
"version": "0.8.0",
"mcpName": "io.github.DrBaher/sign-cli",
"publishConfig": {
"access": "public"
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"url": "https://github.com/DrBaher/sign-cli",
"source": "github"
},
"version": "0.7.1",
"version": "0.8.0",
"packages": [
{
"registryType": "npm",
"identifier": "@drbaher/sign-cli",
"version": "0.7.1",
"version": "0.8.0",
"transport": {
"type": "stdio"
}
Expand Down
41 changes: 39 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import "./lib/silence-warnings.js";
import process from "node:process";
import { openDatabase } from "./lib/db.js";
import { requireDropboxApiKey, requireDropboxClientId, resolveDropboxTestMode } from "./lib/dropbox-sign.js";
Expand All @@ -20,6 +21,8 @@ import { runSignerWatch } from "./lib/signer-watch.js";
import {
buildCatalogJson,
findCommand,
findCommandGroup,
formatCommandGroupHelp,
formatCommandHelp,
formatExamples,
formatTopLevelHelp,
Expand Down Expand Up @@ -94,7 +97,7 @@ import { parseFieldSpec } from "./lib/field-placement.js";
import { loadPolicySpec } from "./lib/policy-engine.js";
import { parseImageInput, stampImageOnPdf, type StampPosition } from "./lib/pdf-image-stamp.js";
import { loadRequestSpec } from "./lib/request-spec.js";
import { parsePrefillSpec, parseSignerSpec } from "./lib/util.js";
import { parseBooleanFlag, parsePrefillSpec, parseSignerSpec } from "./lib/util.js";
import { loadWebhookPayloadFile, verifyDropboxCallback } from "./lib/webhook.js";
import { startWebhookServer } from "./lib/webhook-server.js";

Expand Down Expand Up @@ -342,11 +345,36 @@ function resolveProviderTestMode(provider: ReturnType<typeof resolveSignProvider
return resolveDropboxTestMode(flag);
}
if (provider === "signwell") {
return resolveSignWellTestMode(flag);
const testMode = resolveSignWellTestMode(flag);
if (testMode) {
// SignWell defaults to test mode (non-binding, watermarked). That's a footgun
// for "send the real thing" flows, so make it impossible to miss. Banner goes
// to stderr to keep stdout machine-readable.
process.stderr.write(
"\n" +
" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n" +
" β”‚ ⚠ SIGNWELL TEST MODE β€” this document is NON-BINDING. β”‚\n" +
" β”‚ Signatures are watermarked and have no legal effect. β”‚\n" +
" β”‚ To send a real, binding document, pass --test-mode false β”‚\n" +
" β”‚ (or set SIGNWELL_TEST_MODE=false). β”‚\n" +
" β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n\n",
);
}
return testMode;
}
return false;
}

// `--ordered true|false` controls signing order for providers that support
// parallel/unordered signing (SignWell). Undefined means "use the provider
// default" (sequential when there are 2+ signers).
function resolveOrderedFlag(flag?: string): boolean | undefined {
if (flag === undefined) {
return undefined;
}
return parseBooleanFlag(flag, true);
}

async function main(): Promise<void> {
loadEnv();
const parsed = parseArgs(process.argv.slice(2));
Expand Down Expand Up @@ -432,6 +460,12 @@ async function main(): Promise<void> {
return;
}
}
// No exact match β€” fall back to listing subcommands for a parent like "mcp".
const group = findCommandGroup(queryPositionals.join(" "));
if (group.length > 0) {
console.log(formatCommandGroupHelp(queryPositionals.join(" "), group));
return;
}
console.error(`No help entry for "${queryPositionals.join(" ")}". Run \`sign --help\` to list commands.`);
process.exitCode = 1;
return;
Expand Down Expand Up @@ -754,6 +788,7 @@ async function main(): Promise<void> {
provider: selectedProvider,
apiKey: resolveProviderApiKey(selectedProvider),
testMode: resolveProviderTestMode(selectedProvider, flagValue(parsed, "test-mode")),
applySigningOrder: resolveOrderedFlag(flagValue(parsed, "ordered")),
});
console.log(JSON.stringify({
mode: "email-only",
Expand Down Expand Up @@ -2098,6 +2133,7 @@ async function main(): Promise<void> {
provider: selectedProvider,
apiKey: resolveProviderApiKey(selectedProvider),
testMode: resolveProviderTestMode(selectedProvider, flagValue(parsed, "test-mode")),
applySigningOrder: resolveOrderedFlag(flagValue(parsed, "ordered")),
force,
...(flagValue(parsed, "provider") ? { strictProvider } : {}),
});
Expand All @@ -2114,6 +2150,7 @@ async function main(): Promise<void> {
apiKey: resolveProviderApiKey(selectedProvider),
clientId: selectedProvider === "dropbox" ? requireDropboxClientId(flagValue(parsed, "client-id")) : undefined,
testMode: resolveProviderTestMode(selectedProvider, flagValue(parsed, "test-mode")),
applySigningOrder: resolveOrderedFlag(flagValue(parsed, "ordered")),
});
if (selectedProvider === "signwell") {
const document = (result.responseBody as any) ?? {};
Expand Down
19 changes: 17 additions & 2 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { createRequire } from "node:module";
import type { DatabaseSync } from "node:sqlite";
import { applyPendingMigrations } from "./migrations.js";
import { SignCliError } from "./sign-error.js";

export type SqliteDb = DatabaseSync;

// node:sqlite is loaded lazily (require, not a static ESM import) so its
// "ExperimentalWarning" is emitted through the patched process.emitWarning in
// silence-warnings.js and can be filtered. A static import would load the builtin
// during module linking β€” before that patch runs β€” and the warning would leak.
const nodeRequire = createRequire(import.meta.url);
let cachedDatabaseSync: typeof DatabaseSync | undefined;
function getDatabaseSync(): typeof DatabaseSync {
if (!cachedDatabaseSync) {
cachedDatabaseSync = (nodeRequire("node:sqlite") as typeof import("node:sqlite")).DatabaseSync;
}
return cachedDatabaseSync;
}

function hasColumn(db: SqliteDb, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
return rows.some((row) => row.name === columnName);
Expand Down Expand Up @@ -37,7 +51,8 @@ export function openDatabase(dbPath: string): SqliteDb {
}
throw err; // Other failures (ENOENT on a truly broken path, etc.) bubble up.
}
const db = new DatabaseSync(resolved);
const DatabaseSyncCtor = getDatabaseSync();
const db = new DatabaseSyncCtor(resolved);
try {
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA synchronous = NORMAL;");
Expand Down
22 changes: 22 additions & 0 deletions src/lib/field-placement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,28 @@ export function parseFieldSpec(raw: string): SignatureField {
};
}

/**
* Map a field position from a bottom-left origin (PDF user space, as emitted by
* pdfjs-based detectors) to the top-left origin that every provider this CLI
* targets β€” Dropbox Sign, SignWell, DocuSign β€” expects for `--field x/y`.
*
* `y` and `pageHeight` must be in the same units (points or pixels at the same
* DPI). `height` is the field box height in those units; pass it so the box's
* top edge lands where you expect (omit it and you get the baseline point, which
* places the box's *top* at the detected line and pushes the field downward).
*
* See docs/field-coordinates.md for the full per-provider contract.
*/
export function bottomLeftToTopLeft(input: {
x: number;
y: number;
pageHeight: number;
height?: number;
}): { x: number; y: number } {
const height = input.height ?? 0;
return { x: input.x, y: input.pageHeight - input.y - height };
}

function dropboxFieldType(type: FieldType): string {
switch (type) {
case "signature": return "signature";
Expand Down
33 changes: 31 additions & 2 deletions src/lib/help-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// sign examples β†’ walkthrough snippets

// Bumped manually on each release; mirrored in package.json.
export const SIGN_CLI_VERSION = "0.7.1";
export const SIGN_CLI_VERSION = "0.8.0";

export type FlagSpec = {
name: string; // e.g. "--request-id" or "--token"
Expand Down Expand Up @@ -140,7 +140,7 @@ export const HELP_CATALOG: CommandSpec[] = [
{ name: "--title", description: "Document title." },
{ name: "--document", description: "Path to a PDF (repeatable for multi-doc)." },
{ name: "--signer", description: "Signer spec name:X,email:Y,order:N (repeatable)." },
{ name: "--field", description: "Field placement signer:N,doc:N,page:N,x:N,y:N,type:signature." },
{ name: "--field", description: "Field placement (repeatable). Keys: signer:N (1-based, matches --signer order), doc:N (0-based document index), page:N (1-based), x:N, y:N (provider pixels, top-left origin), type:signature|initials|date|text|name|email (default signature), width:N, height:N, required:true|false (default true). Anchor strings are not supported via this CLI. See docs/field-coordinates.md for the per-provider coordinate contract." },
{ name: "--prefill", description: "Template prefill name:K,value:V[,signer:N]." },
{ name: "--token-ttl-minutes", description: "Token lifetime in minutes (default 60)." },
{ name: "--auto-approve", description: "true to skip the approval gate (default false)." },
Expand All @@ -167,18 +167,31 @@ export const HELP_CATALOG: CommandSpec[] = [
{
command: "request run-email",
summary: "Convenience: create + send in one step. Auto-approves.",
flags: [
{ name: "--field", description: "Same as `request create --field` (repeatable). See docs/field-coordinates.md." },
{ name: "--test-mode", description: "true|false. SignWell defaults to true (non-binding, watermarked); pass false for a real binding send." },
{ name: "--ordered", description: "true|false (SignWell). true = sequential signing, false = parallel/unordered. Default: sequential when 2+ signers." },
],
},
{
command: "request send",
summary: "Dispatch a created request to the provider.",
flags: [
{ name: "--request-id", required: true, description: "Request id." },
{ name: "--force", description: "Resend even if provider_request_id is already set." },
{ name: "--test-mode", description: "true|false. SignWell defaults to true (non-binding, watermarked); pass false for a real binding send." },
{ name: "--ordered", description: "true|false (SignWell). true = sequential signing, false = parallel/unordered. Default: sequential when 2+ signers." },
],
},
{
command: "request send-embedded",
summary: "Send via the provider's embedded-signing flow.",
flags: [
{ name: "--request-id", required: true, description: "Request id." },
{ name: "--client-id", description: "Embedded client id (Dropbox Sign)." },
{ name: "--test-mode", description: "true|false. SignWell defaults to true (non-binding, watermarked); pass false for a real binding send." },
{ name: "--ordered", description: "true|false (SignWell). true = sequential signing, false = parallel/unordered. Default: sequential when 2+ signers." },
],
},
{
command: "request sign-url",
Expand Down Expand Up @@ -845,6 +858,22 @@ export function findCommand(query: string): CommandSpec | null {
return HELP_CATALOG.find((entry) => entry.command === normalized) ?? null;
}

// A parent command like "mcp" has no entry of its own, but "mcp serve" / "mcp tools"
// do. Return those subcommands so `sign mcp --help` lists them instead of erroring.
export function findCommandGroup(query: string): CommandSpec[] {
const prefix = query.trim().replace(/\s+/gu, " ") + " ";
return HELP_CATALOG.filter((entry) => entry.command.startsWith(prefix));
}

export function formatCommandGroupHelp(query: string, specs: CommandSpec[]): string {
const lines: string[] = [`sign ${query} β€” subcommands`, ""];
for (const spec of specs) {
lines.push(` sign ${spec.command.padEnd(28)} ${spec.summary}`);
}
lines.push("", "Run `sign <command> --help` for focused help on any subcommand.");
return lines.join("\n");
}

export function formatTopLevelHelp(): string {
const lines: string[] = ["sign β€” consent-gated, auditable e-sign CLI", ""];
// Group by first word for readability.
Expand Down
Loading
Loading