528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
|
|
// lib/letter.ts
|
|||
|
|
// SERVER-ONLY letter assembly for resolution PDF generation.
|
|||
|
|
//
|
|||
|
|
// Phase 6 — letter template integration.
|
|||
|
|
//
|
|||
|
|
// Pipeline:
|
|||
|
|
// 1. Load the corrected master template (currently:
|
|||
|
|
// director_change_resolution_master.docx)
|
|||
|
|
// 2. Download each signed signer's signature PNG from PB to a temp file
|
|||
|
|
// 3. Spawn `python3 scripts/subst.py` to:
|
|||
|
|
// - substitute every [PLACEHOLDER] with real values
|
|||
|
|
// - embed each signature PNG above its signature line
|
|||
|
|
// (we use python-docx via the standalone subst.py script — it preserves
|
|||
|
|
// run formatting cleanly, which is painful with the npm docx package
|
|||
|
|
// for in-place edits)
|
|||
|
|
// 4. Spawn `soffice --headless --convert-to pdf` to render the docx
|
|||
|
|
// 5. Return the PDF as a Buffer (callers upload it to PB / stream to user)
|
|||
|
|
|
|||
|
|
import "server-only";
|
|||
|
|
import { spawn } from "node:child_process";
|
|||
|
|
import { promises as fs } from "node:fs";
|
|||
|
|
import * as path from "node:path";
|
|||
|
|
import * as os from "node:os";
|
|||
|
|
import { randomBytes } from "node:crypto";
|
|||
|
|
import { existsSync } from "node:fs";
|
|||
|
|
|
|||
|
|
// ─── Public types ────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export type LetterType = "director_change";
|
|||
|
|
|
|||
|
|
export interface LetterAttendee {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
surname: string;
|
|||
|
|
email: string | null;
|
|||
|
|
director_role: string;
|
|||
|
|
attended: boolean;
|
|||
|
|
must_sign: boolean;
|
|||
|
|
/** SA ID number for the director — used in resolution text. */
|
|||
|
|
director_id_number?: string | null;
|
|||
|
|
/** Cell number, from the director's `telephone` field. Shown under the
|
|||
|
|
* signature line when present. */
|
|||
|
|
telephone?: string | null;
|
|||
|
|
/** Optional ISO date the appointment / resignation takes effect. */
|
|||
|
|
effective_date?: string | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface LetterSigner {
|
|||
|
|
id: string;
|
|||
|
|
attendee_id: string;
|
|||
|
|
status: "pending" | "signed" | "expired";
|
|||
|
|
/**
|
|||
|
|
* Absolute path on disk to the rendered signature PNG.
|
|||
|
|
* Caller must populate this from PB before invoking generateLetter().
|
|||
|
|
* If null/undefined OR file doesn't exist, that slot is left blank.
|
|||
|
|
*/
|
|||
|
|
signature_path: string | null;
|
|||
|
|
signed_at: string | null;
|
|||
|
|
attendee: LetterAttendee;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface LetterCompany {
|
|||
|
|
name: string;
|
|||
|
|
enterprise_no: string;
|
|||
|
|
/** Multi-line address — already formatted. */
|
|||
|
|
address: string;
|
|||
|
|
/** City portion only, for "Signed at COMPANY [ADDRESS_CITY]". */
|
|||
|
|
address_city: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface LetterContext {
|
|||
|
|
letter_type: LetterType;
|
|||
|
|
resolution_id: string;
|
|||
|
|
/** ISO date — YYYY-MM-DD. */
|
|||
|
|
meeting_date: string;
|
|||
|
|
company: LetterCompany;
|
|||
|
|
attendees: LetterAttendee[];
|
|||
|
|
signers: LetterSigner[];
|
|||
|
|
/** Resigning director's "Name Surname (ID)" — null if no resignations. */
|
|||
|
|
resigning_director_label: string | null;
|
|||
|
|
/** Newly appointed director's "Name Surname (ID)" — null if none. */
|
|||
|
|
newly_appointed_director_label: string | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface LetterOutput {
|
|||
|
|
pdf: Buffer;
|
|||
|
|
filename: string;
|
|||
|
|
signatures_embedded: number;
|
|||
|
|
unfilled_placeholders: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The editable Word twin of the letter, before DOCX→PDF conversion. */
|
|||
|
|
export interface LetterDocxOutput {
|
|||
|
|
docx: Buffer;
|
|||
|
|
docxFilename: string;
|
|||
|
|
signatures_embedded: number;
|
|||
|
|
unfilled_placeholders: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Template registry ───────────────────────────────────────────────────
|
|||
|
|
//
|
|||
|
|
// The master template used to live on a KIOXIA volume attached to the Mac
|
|||
|
|
// server (`/Volumes/KIOXIA/.hermes/...`), which does not exist on this PC —
|
|||
|
|
// letter generation failed with ENOENT. The master now lives in the repo at
|
|||
|
|
// assets/templates/. LETTER_TEMPLATE_DIR overrides both.
|
|||
|
|
const TEMPLATE_DIR =
|
|||
|
|
process.env.LETTER_TEMPLATE_DIR?.trim() ||
|
|||
|
|
path.join(process.cwd(), "assets", "templates");
|
|||
|
|
|
|||
|
|
const TEMPLATES: Record<LetterType, string> = {
|
|||
|
|
director_change: "director_change_resolution_master.docx",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function templatePathFor(type: LetterType): string {
|
|||
|
|
const fname = TEMPLATES[type];
|
|||
|
|
if (!fname) {
|
|||
|
|
throw new Error(`No template registered for letter type "${type}"`);
|
|||
|
|
}
|
|||
|
|
const p = path.join(TEMPLATE_DIR, fname);
|
|||
|
|
if (!existsSync(p)) {
|
|||
|
|
throw new Error(
|
|||
|
|
`Letter template not found: ${p}. Set LETTER_TEMPLATE_DIR to the folder ` +
|
|||
|
|
`containing ${fname}.`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
return p;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Public API ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/** Filename stem shared by the PDF and the DOCX, e.g.
|
|||
|
|
* `Resolution_ufc_Oct-09-2026`. One stem so the two files an operator
|
|||
|
|
* downloads are obviously the same document. */
|
|||
|
|
function filenameStem(ctx: LetterContext): string {
|
|||
|
|
const slug =
|
|||
|
|
ctx.company.name
|
|||
|
|
.replace(/[^a-z0-9]+/gi, "-")
|
|||
|
|
.replace(/^-|-$/g, "")
|
|||
|
|
.toLowerCase() || "company";
|
|||
|
|
// ctx.meeting_date is display-formatted ("9 October 2026"), so squash it to
|
|||
|
|
// a filename-safe stamp ("Oct-09-2026"). If it is not in that shape, fall
|
|||
|
|
// back to stripping anything that is not alphanumeric.
|
|||
|
|
const dateSlug = (() => {
|
|||
|
|
const year = ctx.meeting_date.match(/\b(\d{4})\b/)?.[1];
|
|||
|
|
const day = ctx.meeting_date.match(/^\s*(\d{1,2})\b/)?.[1];
|
|||
|
|
const month = ctx.meeting_date.match(/\b([A-Za-z]{3,})\b/)?.[1];
|
|||
|
|
if (year && day && month) return `${month.slice(0, 3)}-${day.padStart(2, "0")}-${year}`;
|
|||
|
|
return ctx.meeting_date.replace(/[^A-Za-z0-9]+/g, "") || "undated";
|
|||
|
|
})();
|
|||
|
|
return `Resolution_${slug}_${dateSlug}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** What buildDocx hands back: the substituted file plus the temp dir holding it.
|
|||
|
|
* The CALLER owns `workDir` and must remove it — the PDF path still needs the
|
|||
|
|
* file on disk for the DOCX→PDF conversion after this returns. */
|
|||
|
|
interface DocxBuild {
|
|||
|
|
workDir: string;
|
|||
|
|
outDocx: string;
|
|||
|
|
filenameStem: string;
|
|||
|
|
signatures_embedded: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Steps 1–3 of the pipeline: write context.json and run subst.py.
|
|||
|
|
*
|
|||
|
|
* Shared by generateLetter (which then converts to PDF) and
|
|||
|
|
* generateLetterDocx (which stops here). Extracting it is what lets the Word
|
|||
|
|
* download reuse the exact same substitution as the emailed PDF instead of
|
|||
|
|
* being a second rendering path that can drift.
|
|||
|
|
*/
|
|||
|
|
async function buildDocx(ctx: LetterContext): Promise<DocxBuild> {
|
|||
|
|
const templatePath = templatePathFor(ctx.letter_type);
|
|||
|
|
await fs.access(templatePath);
|
|||
|
|
|
|||
|
|
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "ybletter-"));
|
|||
|
|
const outDocx = path.join(workDir, "letter.docx");
|
|||
|
|
const ctxPath = path.join(workDir, "context.json");
|
|||
|
|
await fs.mkdir(path.join(workDir, "pdf"), { recursive: true });
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 2. Write context.json — the python side reads the signature paths from
|
|||
|
|
// it, so they must be included.
|
|||
|
|
await fs.writeFile(ctxPath, JSON.stringify(ctx, null, 2), "utf8");
|
|||
|
|
|
|||
|
|
// 3. Run python-docx substitution
|
|||
|
|
//
|
|||
|
|
// Resolve from process.cwd() (the project root), NOT __dirname: inside a
|
|||
|
|
// Next.js server bundle __dirname points at .next/server/app/api/... so a
|
|||
|
|
// __dirname-relative walk resolved to a path that does not exist and python
|
|||
|
|
// failed with "can't open file". Fall back to __dirname for non-Next usage.
|
|||
|
|
const substScript = resolveSubstScript();
|
|||
|
|
const substRes = await run(
|
|||
|
|
resolvePython(),
|
|||
|
|
[substScript, templatePath, outDocx, ctxPath, workDir],
|
|||
|
|
{ timeoutMs: 60_000 }
|
|||
|
|
);
|
|||
|
|
if (substRes.exitCode !== 0) {
|
|||
|
|
throw new Error(
|
|||
|
|
`letter substitution failed (exit ${substRes.exitCode}): ${substRes.stderr.slice(0, 800)}`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
// Nothing downstream can use a half-built temp dir.
|
|||
|
|
fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|||
|
|
throw err;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
workDir,
|
|||
|
|
outDocx,
|
|||
|
|
filenameStem: filenameStem(ctx),
|
|||
|
|
signatures_embedded: ctx.signers.filter(
|
|||
|
|
(s) =>
|
|||
|
|
s.status === "signed" && s.signature_path !== null && s.signature_path !== undefined
|
|||
|
|
).length,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Generate the final signed letter PDF.
|
|||
|
|
*/
|
|||
|
|
export async function generateLetter(
|
|||
|
|
ctx: LetterContext
|
|||
|
|
): Promise<LetterOutput> {
|
|||
|
|
const built = await buildDocx(ctx);
|
|||
|
|
const { workDir, outDocx } = built;
|
|||
|
|
const pdfOutDir = path.join(workDir, "pdf");
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 4. Convert docx → PDF (Microsoft Word first, LibreOffice as fallback)
|
|||
|
|
const pdfPath = await convertDocxToPdf(outDocx, pdfOutDir);
|
|||
|
|
if (!pdfPath) {
|
|||
|
|
throw new Error(
|
|||
|
|
"No DOCX→PDF converter available. Neither Microsoft Word (COM) nor " +
|
|||
|
|
"LibreOffice (soffice) could be used. Install LibreOffice, or set " +
|
|||
|
|
"SOFFICE_BIN to the full path of soffice.exe."
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const pdfBuf = await fs.readFile(pdfPath);
|
|||
|
|
|
|||
|
|
const unfilled_placeholders = await findUnfilledPlaceholders(outDocx);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
pdf: pdfBuf,
|
|||
|
|
filename: `${built.filenameStem}.pdf`,
|
|||
|
|
signatures_embedded: built.signatures_embedded,
|
|||
|
|
unfilled_placeholders,
|
|||
|
|
};
|
|||
|
|
} finally {
|
|||
|
|
// Tidy up temp dir (best effort)
|
|||
|
|
fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Generate the same letter as an editable .docx, stopping before conversion.
|
|||
|
|
*
|
|||
|
|
* This is the Word twin of generateLetter, not a second rendering path: both
|
|||
|
|
* call buildDocx, so the substitution, the signature embedding and the template
|
|||
|
|
* are literally the same document. Only the DOCX→PDF step is skipped, which is
|
|||
|
|
* why the download returns in about a second instead of waiting on Word.
|
|||
|
|
*
|
|||
|
|
* Section 5 has its own equivalent (`lib/shareholder-letter.ts`). It is not
|
|||
|
|
* shared: that module encodes a different legal document, and section 4's
|
|||
|
|
* output must stay exactly as it is.
|
|||
|
|
*/
|
|||
|
|
export async function generateLetterDocx(
|
|||
|
|
ctx: LetterContext
|
|||
|
|
): Promise<LetterDocxOutput> {
|
|||
|
|
const { workDir, outDocx, filenameStem: stem, signatures_embedded } =
|
|||
|
|
await buildDocx(ctx);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const docx = await fs.readFile(outDocx);
|
|||
|
|
return {
|
|||
|
|
docx,
|
|||
|
|
docxFilename: `${stem}.docx`,
|
|||
|
|
signatures_embedded,
|
|||
|
|
unfilled_placeholders: await findUnfilledPlaceholders(outDocx),
|
|||
|
|
};
|
|||
|
|
} finally {
|
|||
|
|
fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Scan the rendered docx for any remaining [PLACEHOLDER] tokens (excluding
|
|||
|
|
* the legal-template boilerplate ones we deliberately preserve).
|
|||
|
|
*/
|
|||
|
|
async function findUnfilledPlaceholders(docxPath: string): Promise<string[]> {
|
|||
|
|
const script = `
|
|||
|
|
import sys
|
|||
|
|
from docx import Document
|
|||
|
|
import re
|
|||
|
|
doc = Document(sys.argv[1])
|
|||
|
|
pat = re.compile(r"\\[[^\\[\\]]+\\]")
|
|||
|
|
seen = set()
|
|||
|
|
for p in doc.paragraphs:
|
|||
|
|
for m in pat.findall(p.text):
|
|||
|
|
seen.add(m)
|
|||
|
|
for t in doc.tables:
|
|||
|
|
for row in t.rows:
|
|||
|
|
for c in row.cells:
|
|||
|
|
for p in c.paragraphs:
|
|||
|
|
for m in pat.findall(p.text):
|
|||
|
|
seen.add(m)
|
|||
|
|
KEEP = {"[resignation and/or appointment]"}
|
|||
|
|
for k in KEEP:
|
|||
|
|
seen.discard(k)
|
|||
|
|
for s in sorted(seen):
|
|||
|
|
print(s)
|
|||
|
|
`;
|
|||
|
|
const scriptPath = path.join(os.tmpdir(), `ybletter-grep-${randomBytes(4).toString("hex")}.py`);
|
|||
|
|
await fs.writeFile(scriptPath, script, "utf8");
|
|||
|
|
try {
|
|||
|
|
const r = await run(resolvePython(), [scriptPath, docxPath], { timeoutMs: 30_000 });
|
|||
|
|
return r.stdout
|
|||
|
|
.split("\n")
|
|||
|
|
.map((l) => l.trim())
|
|||
|
|
.filter(Boolean);
|
|||
|
|
} finally {
|
|||
|
|
fs.unlink(scriptPath).catch(() => {});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/** Convert a .docx to .pdf, returning the PDF path.
|
|||
|
|
*
|
|||
|
|
* Conversion order:
|
|||
|
|
* 1. Microsoft Word via COM — installed on this PC and renders DOCX with
|
|||
|
|
* full fidelity, preserving the legal master template's formatting.
|
|||
|
|
* 2. LibreOffice `soffice` — the path the Mac server used.
|
|||
|
|
* 3. null — the caller reports it and still sends the email.
|
|||
|
|
*/
|
|||
|
|
async function convertDocxToPdf(docxPath: string, outDir: string): Promise<string | null> {
|
|||
|
|
const pdfPath = path.join(
|
|||
|
|
outDir,
|
|||
|
|
path.basename(docxPath).replace(/\.docx$/i, ".pdf")
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 1. Microsoft Word (COM via cscript/VBScript)
|
|||
|
|
const viaWord = await convertViaWord(docxPath, pdfPath);
|
|||
|
|
if (viaWord) return viaWord;
|
|||
|
|
|
|||
|
|
// 2. LibreOffice
|
|||
|
|
const sofficeBin = resolveSoffice();
|
|||
|
|
if (!sofficeBin) return null;
|
|||
|
|
return pdfPath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Word COM automation via a generated VBScript.
|
|||
|
|
*
|
|||
|
|
* PowerShell's COM interop mangles Word's positional arguments
|
|||
|
|
* ("Cannot convert the True value of type bool to type Object"), and
|
|||
|
|
* cscript.exe is present on every Windows box. VBScript's late binding
|
|||
|
|
* drives Word cleanly, and Word renders DOCX with full fidelity — which
|
|||
|
|
* matters because this is a legal template.
|
|||
|
|
*
|
|||
|
|
* Returns the PDF path on success, null on any failure. */
|
|||
|
|
async function convertViaWord(docxPath: string, pdfPath: string): Promise<string | null> {
|
|||
|
|
const vbsPath = path.join(
|
|||
|
|
os.tmpdir(),
|
|||
|
|
`ybletter-word-${randomBytes(4).toString("hex")}.vbs`
|
|||
|
|
);
|
|||
|
|
// Quote defensively — paths may contain spaces or ampersands.
|
|||
|
|
const q = (s: string) => `"${s.replace(/"/g, '""')}"`;
|
|||
|
|
const script = [
|
|||
|
|
'Set word = CreateObject("Word.Application")',
|
|||
|
|
"word.Visible = False",
|
|||
|
|
"word.DisplayAlerts = 0",
|
|||
|
|
`Set doc = word.Documents.Open(${q(docxPath)})`,
|
|||
|
|
`doc.ExportAsFixedFormat ${q(pdfPath)}, 17`,
|
|||
|
|
"doc.Close 0",
|
|||
|
|
"word.Quit",
|
|||
|
|
'WScript.Echo "OK"',
|
|||
|
|
].join("\r\n");
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await fs.writeFile(vbsPath, script, "utf8");
|
|||
|
|
const res = await run("cscript.exe", ["//NoLogo", "//B", vbsPath], {
|
|||
|
|
timeoutMs: 120_000,
|
|||
|
|
});
|
|||
|
|
if (res.exitCode !== 0) {
|
|||
|
|
console.error(
|
|||
|
|
"[letter] Word conversion failed:",
|
|||
|
|
(res.stderr || res.stdout).slice(0, 300)
|
|||
|
|
);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return existsSync(pdfPath) ? pdfPath : null;
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(
|
|||
|
|
"[letter] Word conversion threw:",
|
|||
|
|
e instanceof Error ? e.message.slice(0, 300) : e
|
|||
|
|
);
|
|||
|
|
return null;
|
|||
|
|
} finally {
|
|||
|
|
fs.unlink(vbsPath).catch(() => {});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Locate the LibreOffice CLI.
|
|||
|
|
*
|
|||
|
|
* `soffice` is on PATH under Homebrew (macOS) and most Linux images, but NOT
|
|||
|
|
* by default on Windows — where it lives in a fixed install directory. Check
|
|||
|
|
* the well-known Windows paths so a stock install just works, with
|
|||
|
|
* SOFFICE_BIN as an explicit override. */
|
|||
|
|
function resolveSoffice(): string | null {
|
|||
|
|
const override = process.env.SOFFICE_BIN?.trim();
|
|||
|
|
if (override && existsSync(override)) return override;
|
|||
|
|
|
|||
|
|
const candidates = [
|
|||
|
|
process.env.PROGRAMFILES &&
|
|||
|
|
path.join(process.env.PROGRAMFILES, "LibreOffice", "program", "soffice.exe"),
|
|||
|
|
process.env["PROGRAMFILES(X86)"] &&
|
|||
|
|
path.join(process.env["PROGRAMFILES(X86)"], "LibreOffice", "program", "soffice.exe"),
|
|||
|
|
process.env.LOCALAPPDATA &&
|
|||
|
|
path.join(process.env.LOCALAPPDATA, "Programs", "LibreOffice", "program", "soffice.exe"),
|
|||
|
|
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
|
|||
|
|
"/usr/bin/soffice",
|
|||
|
|
"/usr/local/bin/soffice",
|
|||
|
|
].filter((p): p is string => typeof p === "string" && p.length > 0);
|
|||
|
|
|
|||
|
|
for (const c of candidates) {
|
|||
|
|
if (existsSync(c)) return c;
|
|||
|
|
}
|
|||
|
|
// Last resort: maybe it IS on PATH.
|
|||
|
|
return existsSync(path.join(os.homedir(), ".local", "bin", "soffice"))
|
|||
|
|
? path.join(os.homedir(), ".local", "bin", "soffice")
|
|||
|
|
: null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Locate a Python interpreter that has `python-docx` available.
|
|||
|
|
*
|
|||
|
|
* The pipeline used to spawn the literal "python3", which does not exist on
|
|||
|
|
* Windows — it resolved to the Microsoft Store stub and failed with exit
|
|||
|
|
* 9009. Prefer the project venv (which is where python-docx is installed),
|
|||
|
|
* then an explicit LETTER_PYTHON override, then a system interpreter. */
|
|||
|
|
function resolvePython(): string {
|
|||
|
|
const venv = path.join(
|
|||
|
|
process.cwd(),
|
|||
|
|
"scripts",
|
|||
|
|
".venv",
|
|||
|
|
process.platform === "win32" ? "Scripts" : "bin",
|
|||
|
|
process.platform === "win32" ? "python.exe" : "python"
|
|||
|
|
);
|
|||
|
|
if (existsSync(venv)) return venv;
|
|||
|
|
|
|||
|
|
const override = process.env.LETTER_PYTHON?.trim();
|
|||
|
|
if (override && existsSync(override)) return override;
|
|||
|
|
|
|||
|
|
// Nothing verified — fall back to the platform default and let the error
|
|||
|
|
// message from the spawn surface the real problem.
|
|||
|
|
return process.platform === "win32" ? "python" : "python3";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Locate scripts/subst.py.
|
|||
|
|
*
|
|||
|
|
* `__dirname` is unreliable here: Next.js compiles this module into
|
|||
|
|
* .next/server/app/api/resolutions/[id]/, so walking up from it lands on a
|
|||
|
|
* path that does not exist. process.cwd() is the project root when running
|
|||
|
|
* under `next dev` / `next start`, which is correct. */
|
|||
|
|
function resolveSubstScript(): string {
|
|||
|
|
const override = process.env.SUBST_SCRIPT?.trim();
|
|||
|
|
if (override && existsSync(override)) return override;
|
|||
|
|
|
|||
|
|
const fromCwd = path.join(process.cwd(), "scripts", "subst.py");
|
|||
|
|
if (existsSync(fromCwd)) return fromCwd;
|
|||
|
|
|
|||
|
|
const fromDir = path.resolve(__dirname, "..", "..", "scripts", "subst.py");
|
|||
|
|
if (existsSync(fromDir)) return fromDir;
|
|||
|
|
|
|||
|
|
throw new Error(
|
|||
|
|
`Could not find scripts/subst.py (looked in ${fromCwd} and ${fromDir}). ` +
|
|||
|
|
"Set SUBST_SCRIPT to its full path."
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface RunResult {
|
|||
|
|
exitCode: number;
|
|||
|
|
stdout: string;
|
|||
|
|
stderr: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function run(
|
|||
|
|
cmd: string,
|
|||
|
|
args: string[],
|
|||
|
|
opts: { timeoutMs: number }
|
|||
|
|
): Promise<RunResult> {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|||
|
|
let stdout = "";
|
|||
|
|
let stderr = "";
|
|||
|
|
child.stdout.on("data", (b) => (stdout += b.toString()));
|
|||
|
|
child.stderr.on("data", (b) => (stderr += b.toString()));
|
|||
|
|
const timer = setTimeout(() => {
|
|||
|
|
child.kill("SIGKILL");
|
|||
|
|
reject(new Error(`${cmd} timed out after ${opts.timeoutMs}ms`));
|
|||
|
|
}, opts.timeoutMs);
|
|||
|
|
child.on("error", (err) => {
|
|||
|
|
clearTimeout(timer);
|
|||
|
|
reject(err);
|
|||
|
|
});
|
|||
|
|
child.on("close", (code) => {
|
|||
|
|
clearTimeout(timer);
|
|||
|
|
resolve({ exitCode: code ?? -1, stdout, stderr });
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Save a signer's signature PNG bytes (from PB file URL) to a temp file.
|
|||
|
|
* Returns absolute path; caller is responsible for cleanup if needed
|
|||
|
|
* (typically we let the OS clean up /tmp eventually).
|
|||
|
|
*/
|
|||
|
|
export async function saveSignatureToTempFile(
|
|||
|
|
signerId: string,
|
|||
|
|
pngBytes: Buffer
|
|||
|
|
): Promise<string> {
|
|||
|
|
const dir = path.join(os.tmpdir(), "ybsigs");
|
|||
|
|
await fs.mkdir(dir, { recursive: true });
|
|||
|
|
const fname = `${signerId}-${randomBytes(4).toString("hex")}.png`;
|
|||
|
|
const fullPath = path.join(dir, fname);
|
|||
|
|
await fs.writeFile(fullPath, pngBytes);
|
|||
|
|
return fullPath;
|
|||
|
|
}
|