@tiangong-ai/cli 0.0.48 → 0.0.50

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.
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
- import { chmod, link, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, } from "node:fs/promises";
4
- import { hostname, homedir, platform } from "node:os";
5
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { chmod, link, lstat, mkdtemp, mkdir, open, readFile, readdir, realpath, rename, rm, } from "node:fs/promises";
4
+ import { hostname, homedir, platform, tmpdir } from "node:os";
5
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { setTimeout as sleep } from "node:timers/promises";
7
7
  import { CliError } from "../../errors.js";
8
8
  import { loadCapabilityDeclarations } from "./capabilities.js";
@@ -1033,7 +1033,7 @@ async function containsUnmanagedPathCliFallback(root) {
1033
1033
  };
1034
1034
  return inspectDirectory(root, 0);
1035
1035
  }
1036
- async function findAmbientExecutable(environment, executable) {
1036
+ async function findAmbientExecutable(environment, executable, options = {}) {
1037
1037
  const pathValue = environment.PATH;
1038
1038
  if (!pathValue)
1039
1039
  return null;
@@ -1042,7 +1042,7 @@ async function findAmbientExecutable(environment, executable) {
1042
1042
  if (!directory)
1043
1043
  continue;
1044
1044
  for (const suffix of suffixes) {
1045
- const candidate = join(directory, `${executable}${suffix}`);
1045
+ const candidate = resolve(directory, `${executable}${suffix}`);
1046
1046
  const info = await lstat(candidate).catch(() => undefined);
1047
1047
  if (!info || (!info.isFile() && !info.isSymbolicLink()))
1048
1048
  continue;
@@ -1050,8 +1050,12 @@ async function findAmbientExecutable(environment, executable) {
1050
1050
  if (!resolved)
1051
1051
  continue;
1052
1052
  const resolvedInfo = await lstat(resolved).catch(() => undefined);
1053
- if (resolvedInfo?.isFile() && !resolvedInfo.isSymbolicLink())
1054
- return { path: resolved, ignoredByExactInvocation: true };
1053
+ if (resolvedInfo?.isFile() && !resolvedInfo.isSymbolicLink()) {
1054
+ const path = options.preserveLeafSymlink
1055
+ ? join(await realpath(dirname(candidate)), basename(candidate))
1056
+ : resolved;
1057
+ return { path, ignoredByExactInvocation: true };
1058
+ }
1055
1059
  }
1056
1060
  }
1057
1061
  return null;
@@ -1446,7 +1450,16 @@ export async function doctorResearchSetup(workspace, options = {}) {
1446
1450
  : `Run research setup credential set --id ${credential.id} --prompt --workspace ${root}.`,
1447
1451
  });
1448
1452
  }
1449
- await appendDependencyChecks(checks, selected, runner, root, environment);
1453
+ const authoringRuntime = await resolveAuthoringRuntime(selected, environment);
1454
+ await appendDependencyChecks(checks, plan, selected, runner, root, environment, authoringRuntime);
1455
+ await appendAuthoringCanaryChecks(checks, {
1456
+ plan,
1457
+ selected,
1458
+ runner,
1459
+ root,
1460
+ environment,
1461
+ runtime: authoringRuntime,
1462
+ });
1450
1463
  let capabilityDoctor = null;
1451
1464
  const blockingBeforeLive = checks
1452
1465
  .map((check) => normalizeSetupDoctorCheck(check, selected))
@@ -2926,10 +2939,10 @@ async function runAcademicPaperCompanion(input) {
2926
2939
  exitCode: 2,
2927
2940
  });
2928
2941
  }
2929
- const script = join(input.skillDirectory, "scripts", "fetch.py");
2930
- await requireRegularCompanionFile(input.root, script, "adapter script");
2942
+ const runtimeScript = join(input.skillDirectory, "scripts", "runtime.py");
2943
+ await requireRegularCompanionFile(input.root, runtimeScript, "locked runtime script");
2931
2944
  const timeoutSeconds = normalizedCompanionTimeout(input.input.timeoutSeconds, 30, 600);
2932
- const args = [script];
2945
+ const args = [runtimeScript, "fetch"];
2933
2946
  if (doi)
2934
2947
  args.push(doi);
2935
2948
  else
@@ -2954,6 +2967,18 @@ async function runAcademicPaperCompanion(input) {
2954
2967
  timeoutMs: (timeoutSeconds + 60) * 4 * 1_000,
2955
2968
  });
2956
2969
  const envelope = parseCompanionJson(execution.stdout, input.root, "academic-paper-download");
2970
+ if (execution.exitCode !== 0 && envelope.ok === false && isObject(envelope.error)) {
2971
+ const runtimeErrorCode = safeAcademicPaperRuntimeErrorCode(envelope.error.code);
2972
+ throw setupError({
2973
+ code: "RESEARCH_SETUP_COMPANION_RUNTIME_INVALID",
2974
+ step: "companion-runtime",
2975
+ reason: `The academic paper locked runtime failed preflight (${runtimeErrorCode}).`,
2976
+ minimumAction: "From the verified installed academic-paper-download Skill directory, run `python3 scripts/runtime.py bootstrap --locked --json`, review the result, then rerun setup doctor and the companion command.",
2977
+ retryCommand: `tiangong-ai research setup doctor --workspace ${input.root} --json`,
2978
+ exitCode: 3,
2979
+ diagnostics: { runtimeErrorCode },
2980
+ });
2981
+ }
2957
2982
  const results = isObject(envelope.data) && Array.isArray(envelope.data.results) ? envelope.data.results : [];
2958
2983
  if (results.length !== 1 || !isObject(results[0])) {
2959
2984
  throw setupError({
@@ -3073,6 +3098,18 @@ async function runAcademicPaperCompanion(input) {
3073
3098
  next: "Admit the exact PDF or a derived hash-bound view as a declared research input.",
3074
3099
  };
3075
3100
  }
3101
+ function safeAcademicPaperRuntimeErrorCode(value) {
3102
+ const allowed = new Set([
3103
+ "python_incompatible",
3104
+ "lock_missing",
3105
+ "lock_invalid",
3106
+ "runtime_missing",
3107
+ "runtime_invalid",
3108
+ "runtime_io_error",
3109
+ "dependency_install_failed",
3110
+ ]);
3111
+ return typeof value === "string" && allowed.has(value) ? value : "runtime_error";
3112
+ }
3076
3113
  function companionEnvironment(source) {
3077
3114
  const result = installerEnvironment(source);
3078
3115
  delete result.CI;
@@ -3475,6 +3512,10 @@ function setupDomainReadiness(checks, scope) {
3475
3512
  return "NOT_REQUIRED";
3476
3513
  if (matching.some((check) => check.blocking && check.status === "fail"))
3477
3514
  return "BLOCKED";
3515
+ if (scope === "authoring" &&
3516
+ matching.some((check) => check.componentGate && check.status === "fail")) {
3517
+ return "BLOCKED";
3518
+ }
3478
3519
  return matching.some((check) => check.status !== "pass") ? "DEGRADED" : "READY";
3479
3520
  }
3480
3521
  function requireAbsoluteWorkspace(value) {
@@ -3725,6 +3766,7 @@ function installerEnvironment(source) {
3725
3766
  "SSL_CERT_FILE",
3726
3767
  "SSL_CERT_DIR",
3727
3768
  "NODE_EXTRA_CA_CERTS",
3769
+ "NODE_PATH",
3728
3770
  "CODEX_HOME",
3729
3771
  "CLAUDE_CONFIG_DIR",
3730
3772
  "VIRTUAL_ENV",
@@ -3893,7 +3935,99 @@ async function setupDoctorCheck(checks, id, category, callback, failureMinimumAc
3893
3935
  });
3894
3936
  }
3895
3937
  }
3896
- async function appendDependencyChecks(checks, selected, runner, root, environment) {
3938
+ const AUTHORING_PYTHON_PROBES = {
3939
+ "authoring:defusedxml": {
3940
+ distribution: "defusedxml",
3941
+ module: "defusedxml",
3942
+ exactVersion: "0.7.1",
3943
+ },
3944
+ "authoring:lxml": { distribution: "lxml", module: "lxml.etree", exactVersion: null },
3945
+ "authoring:pillow": { distribution: "Pillow", module: "PIL", exactVersion: null },
3946
+ "authoring:python-pptx": {
3947
+ distribution: "python-pptx",
3948
+ module: "pptx",
3949
+ exactVersion: null,
3950
+ },
3951
+ "authoring:markitdown": {
3952
+ distribution: "markitdown",
3953
+ module: "markitdown",
3954
+ exactVersion: "0.1.7",
3955
+ },
3956
+ "authoring:pypdf": { distribution: "pypdf", module: "pypdf", exactVersion: null },
3957
+ "authoring:pdfplumber": {
3958
+ distribution: "pdfplumber",
3959
+ module: "pdfplumber",
3960
+ exactVersion: null,
3961
+ },
3962
+ "authoring:reportlab": {
3963
+ distribution: "reportlab",
3964
+ module: "reportlab",
3965
+ exactVersion: null,
3966
+ },
3967
+ "authoring:pdf2image": {
3968
+ distribution: "pdf2image",
3969
+ module: "pdf2image",
3970
+ exactVersion: null,
3971
+ },
3972
+ "authoring:openpyxl": {
3973
+ distribution: "openpyxl",
3974
+ module: "openpyxl",
3975
+ exactVersion: null,
3976
+ },
3977
+ "authoring:pandas": { distribution: "pandas", module: "pandas", exactVersion: null },
3978
+ };
3979
+ const AUTHORING_NODE_PROBES = {
3980
+ "authoring:node-docx": "docx",
3981
+ "authoring:node-pptxgenjs": "pptxgenjs",
3982
+ };
3983
+ const AUTHORING_COMMAND_PROBES = {
3984
+ "authoring:pandoc": "pandoc",
3985
+ "authoring:libreoffice": "soffice",
3986
+ "authoring:poppler": "pdftoppm",
3987
+ "authoring:zip": "zip",
3988
+ "authoring:unzip": "unzip",
3989
+ };
3990
+ async function resolveAuthoringRuntime(selected, environment) {
3991
+ if (!selected.some((skill) => skill.role === "post-closure-authoring"))
3992
+ return null;
3993
+ const resolveCommand = async (name) => (await findAmbientExecutable(environment, name, {
3994
+ preserveLeafSymlink: name === "python3",
3995
+ }))?.path ?? null;
3996
+ const [python, node, pandoc, soffice, pdftoppm, zip, unzip] = await Promise.all([
3997
+ resolveCommand("python3"),
3998
+ resolveCommand("node"),
3999
+ resolveCommand("pandoc"),
4000
+ resolveCommand("soffice"),
4001
+ resolveCommand("pdftoppm"),
4002
+ resolveCommand("zip"),
4003
+ resolveCommand("unzip"),
4004
+ ]);
4005
+ return { python, node, pandoc, soffice, pdftoppm, zip, unzip };
4006
+ }
4007
+ function requireAuthoringRuntimeExecutable(value, minimumAction) {
4008
+ if (!value)
4009
+ throw new Error(minimumAction);
4010
+ return value;
4011
+ }
4012
+ function authoringEnvironment(source, runtime) {
4013
+ const result = installerEnvironment(source);
4014
+ const commandDirectories = [
4015
+ runtime?.soffice,
4016
+ runtime?.pdftoppm,
4017
+ runtime?.pandoc,
4018
+ runtime?.zip,
4019
+ runtime?.unzip,
4020
+ ]
4021
+ .filter((value) => Boolean(value))
4022
+ .map(dirname);
4023
+ if (commandDirectories.length) {
4024
+ result.PATH = [...new Set(commandDirectories), result.PATH ?? ""]
4025
+ .filter(Boolean)
4026
+ .join(delimiter);
4027
+ }
4028
+ return result;
4029
+ }
4030
+ async function appendDependencyChecks(checks, plan, selected, runner, root, environment, authoringRuntime) {
3897
4031
  const dependencies = [
3898
4032
  ...new Map(selected
3899
4033
  .flatMap((skill) => skill.dependencies)
@@ -3913,7 +4047,7 @@ async function appendDependencyChecks(checks, selected, runner, root, environmen
3913
4047
  await setupDoctorCheck(checks, `dependency.${dependency.id}`, "dependency", async () => {
3914
4048
  if (dependency.id === "python-3.10") {
3915
4049
  const result = await runner({
3916
- command: "python3",
4050
+ command: authoringRuntime?.python ?? "python3",
3917
4051
  args: ["--version"],
3918
4052
  cwd: root,
3919
4053
  environment: installerEnvironment(environment),
@@ -3929,23 +4063,339 @@ async function appendDependencyChecks(checks, selected, runner, root, environmen
3929
4063
  return `${versionText.trim()} satisfies ${dependency.requirement}.`;
3930
4064
  }
3931
4065
  if (dependency.id === "academic-paper-download:pypdf") {
4066
+ const skill = setupSkill("tiangong.academic-paper-download");
4067
+ const skillDirectory = await verifiedCompanionSkillDirectory(plan, skill);
4068
+ const runtimeScript = join(skillDirectory, "scripts", "runtime.py");
4069
+ await requireRegularCompanionFile(root, runtimeScript, "locked runtime script");
3932
4070
  const result = await runner({
3933
4071
  command: "python3",
3934
- args: ["-c", "import importlib.metadata as m; print(m.version('pypdf'))"],
4072
+ args: [runtimeScript, "doctor", "--json"],
4073
+ cwd: root,
4074
+ environment: installerEnvironment(environment),
4075
+ timeoutMs: 15_000,
4076
+ });
4077
+ const envelope = parseCompanionJson(result.stdout, root, "academic-paper-download locked runtime doctor");
4078
+ const data = isObject(envelope.data) ? envelope.data : {};
4079
+ if (result.exitCode !== 0 || envelope.ok !== true || data.pypdf !== "6.14.2") {
4080
+ throw new Error("The verified academic paper locked runtime is not ready.");
4081
+ }
4082
+ return `${dependency.requirement} is ready.`;
4083
+ }
4084
+ const pythonProbe = AUTHORING_PYTHON_PROBES[dependency.id];
4085
+ if (pythonProbe) {
4086
+ const python = requireAuthoringRuntimeExecutable(authoringRuntime?.python ?? null, dependency.minimumAction);
4087
+ const result = await runner({
4088
+ command: python,
4089
+ args: [
4090
+ "-c",
4091
+ `import importlib.metadata as m; import ${pythonProbe.module}; print(m.version(${JSON.stringify(pythonProbe.distribution)}))`,
4092
+ ],
3935
4093
  cwd: root,
3936
4094
  environment: installerEnvironment(environment),
3937
4095
  timeoutMs: 15_000,
3938
4096
  });
3939
4097
  const observed = result.stdout.trim();
3940
- if (result.exitCode !== 0 || observed !== "6.14.2") {
3941
- throw new Error(`${dependency.requirement} is not active in the selected python3 environment.`);
4098
+ if (result.exitCode !== 0 ||
4099
+ !observed ||
4100
+ (pythonProbe.exactVersion !== null && observed !== pythonProbe.exactVersion)) {
4101
+ throw new Error(`${dependency.requirement} is not active in the bound authoring Python.`);
3942
4102
  }
3943
- return `${dependency.requirement} is active.`;
4103
+ return `${dependency.requirement} is active (version ${observed}).`;
4104
+ }
4105
+ const nodeProbe = AUTHORING_NODE_PROBES[dependency.id];
4106
+ if (nodeProbe) {
4107
+ const node = requireAuthoringRuntimeExecutable(authoringRuntime?.node ?? null, dependency.minimumAction);
4108
+ const result = await runner({
4109
+ command: node,
4110
+ args: [
4111
+ "-e",
4112
+ `const resolved=require.resolve(${JSON.stringify(nodeProbe)}); process.stdout.write(resolved);`,
4113
+ ],
4114
+ cwd: root,
4115
+ environment: authoringEnvironment(environment, authoringRuntime),
4116
+ timeoutMs: 15_000,
4117
+ });
4118
+ if (result.exitCode !== 0 || !result.stdout.trim()) {
4119
+ throw new Error(`${dependency.requirement} is not resolvable by the bound authoring Node.`);
4120
+ }
4121
+ return `${dependency.requirement} is resolvable.`;
4122
+ }
4123
+ const commandKey = AUTHORING_COMMAND_PROBES[dependency.id];
4124
+ if (commandKey) {
4125
+ const command = requireAuthoringRuntimeExecutable(authoringRuntime?.[commandKey] ?? null, dependency.minimumAction);
4126
+ const result = await runner({
4127
+ command,
4128
+ args: ["pdftoppm", "zip", "unzip"].includes(commandKey) ? ["-v"] : ["--version"],
4129
+ cwd: root,
4130
+ environment: authoringEnvironment(environment, authoringRuntime),
4131
+ timeoutMs: 15_000,
4132
+ });
4133
+ if (result.exitCode !== 0)
4134
+ throw new Error(`${dependency.requirement} is unavailable.`);
4135
+ return `${dependency.requirement} is executable.`;
3944
4136
  }
3945
4137
  throw new Error(`No automatic dependency check is declared for ${dependency.id}. ${dependency.minimumAction}`);
3946
4138
  }, dependency.minimumAction);
3947
4139
  }
3948
4140
  }
4141
+ async function appendAuthoringCanaryChecks(checks, input) {
4142
+ for (const skill of input.selected.filter((candidate) => candidate.role === "post-closure-authoring" &&
4143
+ ["anthropic.docx", "anthropic.pdf", "anthropic.pptx", "anthropic.xlsx"].includes(candidate.id))) {
4144
+ const failedPrerequisites = checks.filter((check) => check.id.startsWith("dependency.") &&
4145
+ check.status === "fail" &&
4146
+ setupCheckComponentIds(check, input.selected).includes(skill.id));
4147
+ if (failedPrerequisites.length) {
4148
+ checks.push({
4149
+ id: `authoring-canary.${skill.id}`,
4150
+ category: "authoring-canary",
4151
+ scope: "authoring",
4152
+ componentIds: [skill.id],
4153
+ status: "fail",
4154
+ detail: `Functional canary was not started because prerequisite checks failed: ${failedPrerequisites.map((check) => check.id).join(", ")}.`,
4155
+ minimumAction: "Resolve every declared runtime and dependency prerequisite, then rerun setup doctor; setup never installs them.",
4156
+ blocking: false,
4157
+ componentGate: true,
4158
+ requiredFor: [`component:${skill.id}`],
4159
+ skippedBecause: failedPrerequisites.map((check) => check.id).join(", "),
4160
+ });
4161
+ continue;
4162
+ }
4163
+ await setupDoctorCheck(checks, `authoring-canary.${skill.id}`, "authoring-canary", async () => {
4164
+ const temporary = await mkdtemp(join(tmpdir(), `tiangong-authoring-${skill.skillName}-`));
4165
+ try {
4166
+ await runAuthoringCanary({ ...input, skill, temporary });
4167
+ return `${skill.id} completed its exact-file synthetic functional canary.`;
4168
+ }
4169
+ finally {
4170
+ await rm(temporary, { recursive: true, force: true });
4171
+ }
4172
+ }, "Inspect the failed pinned helper/package/command step, repair the explicit authoring runtime outside this CLI, and rerun setup doctor.");
4173
+ Object.assign(checks.at(-1), {
4174
+ scope: "authoring",
4175
+ componentIds: [skill.id],
4176
+ blocking: false,
4177
+ componentGate: true,
4178
+ requiredFor: [`component:${skill.id}`],
4179
+ });
4180
+ }
4181
+ }
4182
+ async function runAuthoringCanary(input) {
4183
+ const environment = authoringEnvironment(input.environment, input.runtime);
4184
+ environment.PYTHONDONTWRITEBYTECODE = "1";
4185
+ if (input.skill.id === "anthropic.docx")
4186
+ return runDocxAuthoringCanary(input, environment);
4187
+ if (input.skill.id === "anthropic.pdf")
4188
+ return runPdfAuthoringCanary(input, environment);
4189
+ if (input.skill.id === "anthropic.pptx")
4190
+ return runPptxAuthoringCanary(input, environment);
4191
+ if (input.skill.id === "anthropic.xlsx")
4192
+ return runXlsxAuthoringCanary(input, environment);
4193
+ throw new Error("Unsupported authoring canary.");
4194
+ }
4195
+ async function runDocxAuthoringCanary(input, environment) {
4196
+ const node = requireAuthoringRuntimeExecutable(input.runtime?.node ?? null, "Node is missing.");
4197
+ const python = requireAuthoringRuntimeExecutable(input.runtime?.python ?? null, "Python is missing.");
4198
+ const pandoc = requireAuthoringRuntimeExecutable(input.runtime?.pandoc ?? null, "Pandoc is missing.");
4199
+ const pdftoppm = requireAuthoringRuntimeExecutable(input.runtime?.pdftoppm ?? null, "Poppler is missing.");
4200
+ const skillDirectory = await verifiedCompanionSkillDirectory(input.plan, input.skill);
4201
+ const validator = join(skillDirectory, "scripts", "office", "validate.py");
4202
+ const soffice = join(skillDirectory, "scripts", "office", "soffice.py");
4203
+ await Promise.all([
4204
+ requireRegularCompanionFile(input.root, validator, "DOCX validator"),
4205
+ requireRegularCompanionFile(input.root, soffice, "DOCX LibreOffice helper"),
4206
+ ]);
4207
+ const document = join(input.temporary, "docx-canary.docx");
4208
+ await authoringStep(input, environment, "docx.create", node, [
4209
+ "-e",
4210
+ DOCX_CANARY_SCRIPT,
4211
+ document,
4212
+ ]);
4213
+ await requireAuthoringArtifact(document, 128);
4214
+ await authoringStep(input, environment, "docx.validate", python, [validator, document]);
4215
+ const text = await authoringStep(input, environment, "docx.extract", pandoc, [
4216
+ "-t",
4217
+ "plain",
4218
+ document,
4219
+ ]);
4220
+ if (!text.stdout.includes("TIANGONG_DOCX_CANARY")) {
4221
+ throw new Error("DOCX text extraction did not return the exact canary sentinel.");
4222
+ }
4223
+ await authoringStep(input, environment, "docx.render", python, [soffice, "--headless", "--convert-to", "pdf", "--outdir", input.temporary, document], 60_000);
4224
+ const pdf = join(input.temporary, "docx-canary.pdf");
4225
+ await requireAuthoringArtifact(pdf, 128);
4226
+ const renderPrefix = join(input.temporary, "docx-page");
4227
+ await authoringStep(input, environment, "docx.rasterize", pdftoppm, [
4228
+ "-f",
4229
+ "1",
4230
+ "-singlefile",
4231
+ "-png",
4232
+ pdf,
4233
+ renderPrefix,
4234
+ ]);
4235
+ await requireAuthoringArtifact(`${renderPrefix}.png`, 64);
4236
+ }
4237
+ async function runPdfAuthoringCanary(input, environment) {
4238
+ const python = requireAuthoringRuntimeExecutable(input.runtime?.python ?? null, "Python is missing.");
4239
+ const pdftoppm = requireAuthoringRuntimeExecutable(input.runtime?.pdftoppm ?? null, "Poppler is missing.");
4240
+ const skillDirectory = await verifiedCompanionSkillDirectory(input.plan, input.skill);
4241
+ const converter = join(skillDirectory, "scripts", "convert_pdf_to_images.py");
4242
+ const validationImage = join(skillDirectory, "scripts", "create_validation_image.py");
4243
+ await Promise.all([
4244
+ requireRegularCompanionFile(input.root, converter, "PDF image converter"),
4245
+ requireRegularCompanionFile(input.root, validationImage, "PDF validation image helper"),
4246
+ ]);
4247
+ const pdf = join(input.temporary, "pdf-canary.pdf");
4248
+ await authoringStep(input, environment, "pdf.create", python, ["-c", PDF_CANARY_SCRIPT, pdf]);
4249
+ await requireAuthoringArtifact(pdf, 128);
4250
+ const parsed = await authoringStep(input, environment, "pdf.parse", python, [
4251
+ "-c",
4252
+ PDF_VERIFY_SCRIPT,
4253
+ pdf,
4254
+ ]);
4255
+ if (!parsed.stdout.includes("TIANGONG_PDF_CANARY")) {
4256
+ throw new Error("PDF parser did not return the exact canary sentinel.");
4257
+ }
4258
+ const renderPrefix = join(input.temporary, "pdf-page");
4259
+ await authoringStep(input, environment, "pdf.rasterize", pdftoppm, [
4260
+ "-f",
4261
+ "1",
4262
+ "-singlefile",
4263
+ "-png",
4264
+ pdf,
4265
+ renderPrefix,
4266
+ ]);
4267
+ await requireAuthoringArtifact(`${renderPrefix}.png`, 64);
4268
+ const pages = join(input.temporary, "pdf-pages");
4269
+ await mkdir(pages);
4270
+ await authoringStep(input, environment, "pdf.convert-images", python, [converter, pdf, pages]);
4271
+ const page = join(pages, "page_1.png");
4272
+ await requireAuthoringArtifact(page, 64);
4273
+ const fields = join(input.temporary, "pdf-fields.json");
4274
+ await writeJsonAtomic(fields, { form_fields: [] });
4275
+ const annotated = join(input.temporary, "pdf-validation.png");
4276
+ await authoringStep(input, environment, "pdf.validation-image", python, [
4277
+ validationImage,
4278
+ "1",
4279
+ fields,
4280
+ page,
4281
+ annotated,
4282
+ ]);
4283
+ await requireAuthoringArtifact(annotated, 64);
4284
+ }
4285
+ async function runPptxAuthoringCanary(input, environment) {
4286
+ const node = requireAuthoringRuntimeExecutable(input.runtime?.node ?? null, "Node is missing.");
4287
+ const python = requireAuthoringRuntimeExecutable(input.runtime?.python ?? null, "Python is missing.");
4288
+ const skillDirectory = await verifiedCompanionSkillDirectory(input.plan, input.skill);
4289
+ const validator = join(skillDirectory, "scripts", "office", "validate.py");
4290
+ const thumbnail = join(skillDirectory, "scripts", "thumbnail.py");
4291
+ await Promise.all([
4292
+ requireRegularCompanionFile(input.root, validator, "PPTX validator"),
4293
+ requireRegularCompanionFile(input.root, thumbnail, "PPTX thumbnail helper"),
4294
+ ]);
4295
+ const presentation = join(input.temporary, "pptx-canary.pptx");
4296
+ await authoringStep(input, environment, "pptx.create", node, [
4297
+ "-e",
4298
+ PPTX_CANARY_SCRIPT,
4299
+ presentation,
4300
+ ]);
4301
+ await requireAuthoringArtifact(presentation, 128);
4302
+ await authoringStep(input, environment, "pptx.validate", python, [validator, presentation]);
4303
+ const text = await authoringStep(input, environment, "pptx.extract", python, [
4304
+ "-m",
4305
+ "markitdown",
4306
+ presentation,
4307
+ ]);
4308
+ if (!text.stdout.includes("TIANGONG_PPTX_CANARY")) {
4309
+ throw new Error("PPTX MarkItDown did not return the exact canary sentinel.");
4310
+ }
4311
+ const thumbnailPrefix = join(input.temporary, "pptx-grid");
4312
+ await authoringStep(input, environment, "pptx.thumbnail", python, [thumbnail, presentation, thumbnailPrefix, "--cols", "1"], 60_000);
4313
+ await requireAuthoringArtifact(`${thumbnailPrefix}.jpg`, 64);
4314
+ }
4315
+ async function runXlsxAuthoringCanary(input, environment) {
4316
+ const python = requireAuthoringRuntimeExecutable(input.runtime?.python ?? null, "Python is missing.");
4317
+ const skillDirectory = await verifiedCompanionSkillDirectory(input.plan, input.skill);
4318
+ const recalc = join(skillDirectory, "scripts", "recalc.py");
4319
+ await requireRegularCompanionFile(input.root, recalc, "XLSX recalculation helper");
4320
+ const workbook = join(input.temporary, "xlsx-canary.xlsx");
4321
+ await authoringStep(input, environment, "xlsx.create", python, [
4322
+ "-c",
4323
+ XLSX_CANARY_SCRIPT,
4324
+ workbook,
4325
+ ]);
4326
+ await requireAuthoringArtifact(workbook, 128);
4327
+ const recalculated = await authoringStep(input, environment, "xlsx.recalculate", python, [recalc, workbook, "60"], 75_000);
4328
+ let result;
4329
+ try {
4330
+ result = JSON.parse(recalculated.stdout);
4331
+ }
4332
+ catch {
4333
+ throw new Error("XLSX recalculation helper did not return JSON.");
4334
+ }
4335
+ if (!isObject(result) || result.status !== "success" || result.total_errors !== 0) {
4336
+ throw new Error("XLSX recalculation helper did not complete without formula errors.");
4337
+ }
4338
+ const cached = await authoringStep(input, environment, "xlsx.verify-cache", python, [
4339
+ "-c",
4340
+ XLSX_VERIFY_SCRIPT,
4341
+ workbook,
4342
+ ]);
4343
+ if (cached.stdout.trim() !== "5") {
4344
+ throw new Error("XLSX recalculation did not persist the exact cached formula value.");
4345
+ }
4346
+ const markdown = await authoringStep(input, environment, "xlsx.extract", python, [
4347
+ "-m",
4348
+ "markitdown",
4349
+ workbook,
4350
+ ]);
4351
+ const normalizedMarkdown = markdown.stdout.replaceAll("\\_", "_");
4352
+ if (!normalizedMarkdown.includes("TIANGONG_XLSX_CANARY") ||
4353
+ !/(?:^|\D)5(?:\D|$)/u.test(normalizedMarkdown)) {
4354
+ throw new Error("XLSX MarkItDown did not return the exact canary sentinel and value.");
4355
+ }
4356
+ }
4357
+ async function authoringStep(input, environment, step, command, args, timeoutMs = 30_000) {
4358
+ const result = await input.runner({
4359
+ command,
4360
+ args,
4361
+ cwd: input.root,
4362
+ environment,
4363
+ timeoutMs,
4364
+ });
4365
+ if (result.exitCode !== 0) {
4366
+ throw new Error(`Authoring canary step failed (${step}).`);
4367
+ }
4368
+ return result;
4369
+ }
4370
+ async function requireAuthoringArtifact(path, minimumBytes) {
4371
+ const info = await lstat(path).catch(() => undefined);
4372
+ if (!info?.isFile() || info.isSymbolicLink() || info.size < minimumBytes) {
4373
+ throw new Error("Authoring canary did not create its exact expected regular artifact.");
4374
+ }
4375
+ }
4376
+ const DOCX_CANARY_SCRIPT = String.raw `
4377
+ const fs=require("node:fs"); const {Document,Packer,Paragraph}=require("docx");
4378
+ (async()=>{const out=process.argv[1]; const doc=new Document({sections:[{children:[new Paragraph("TIANGONG_DOCX_CANARY")]}]}); fs.writeFileSync(out,await Packer.toBuffer(doc));})().catch(()=>process.exit(1));`;
4379
+ const PDF_CANARY_SCRIPT = String.raw `
4380
+ import sys
4381
+ from reportlab.pdfgen import canvas
4382
+ c=canvas.Canvas(sys.argv[1]); c.drawString(72,720,"TIANGONG_PDF_CANARY"); c.save()`;
4383
+ const PDF_VERIFY_SCRIPT = String.raw `
4384
+ import sys, pdfplumber
4385
+ from pypdf import PdfReader
4386
+ assert len(PdfReader(sys.argv[1]).pages)==1
4387
+ with pdfplumber.open(sys.argv[1]) as p: print(p.pages[0].extract_text())`;
4388
+ const PPTX_CANARY_SCRIPT = String.raw `
4389
+ const pptxgen=require("pptxgenjs");
4390
+ (async()=>{const p=new pptxgen(); p.addSlide().addText("TIANGONG_PPTX_CANARY",{x:1,y:1,w:6,h:1}); await p.writeFile({fileName:process.argv[1]});})().catch(()=>process.exit(1));`;
4391
+ const XLSX_CANARY_SCRIPT = String.raw `
4392
+ import sys
4393
+ from openpyxl import Workbook
4394
+ w=Workbook(); s=w.active; s["A1"]="TIANGONG_XLSX_CANARY"; s["A2"]=2; s["A3"]=3; s["A4"]="=SUM(A2:A3)"; w.save(sys.argv[1])`;
4395
+ const XLSX_VERIFY_SCRIPT = String.raw `
4396
+ import sys
4397
+ from openpyxl import load_workbook
4398
+ w=load_workbook(sys.argv[1],data_only=True,read_only=True); print(w.active["A4"].value); w.close()`;
3949
4399
  async function appendCompanionLiveChecks(checks, input) {
3950
4400
  if (input.selected.some((skill) => skill.id === "tiangong.academic-paper-download")) {
3951
4401
  await appendSemanticScholarLiveCheck(checks, input);