playhead-cli 0.1.0 → 0.1.1

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/dist/mcp/bin.js CHANGED
@@ -1548,6 +1548,27 @@ var init_schema = __esm({
1548
1548
  }
1549
1549
  });
1550
1550
 
1551
+ // src/shared/exit.ts
1552
+ var EXIT, FlowError, InfraError;
1553
+ var init_exit = __esm({
1554
+ "src/shared/exit.ts"() {
1555
+ "use strict";
1556
+ EXIT = {
1557
+ OK: 0,
1558
+ FLOW: 1,
1559
+ QUALITY: 2,
1560
+ INFRA: 3,
1561
+ USAGE: 4
1562
+ };
1563
+ FlowError = class extends Error {
1564
+ exitCode = EXIT.FLOW;
1565
+ };
1566
+ InfraError = class extends Error {
1567
+ exitCode = EXIT.INFRA;
1568
+ };
1569
+ }
1570
+ });
1571
+
1551
1572
  // src/authoring/validate.ts
1552
1573
  var validate_exports = {};
1553
1574
  __export(validate_exports, {
@@ -1730,8 +1751,175 @@ var init_validate = __esm({
1730
1751
  }
1731
1752
  });
1732
1753
 
1754
+ // src/integrations/jira.ts
1755
+ var jira_exports = {};
1756
+ __export(jira_exports, {
1757
+ addComment: () => addComment,
1758
+ assertIssue: () => assertIssue,
1759
+ attachFile: () => attachFile,
1760
+ buildComment: () => buildComment,
1761
+ collectEvidence: () => collectEvidence,
1762
+ jiraConfigFromEnv: () => jiraConfigFromEnv,
1763
+ reportToJira: () => reportToJira
1764
+ });
1765
+ import { readFile as readFile5, stat } from "fs/promises";
1766
+ import { join as join9, basename } from "path";
1767
+ import { existsSync as existsSync4 } from "fs";
1768
+ function jiraConfigFromEnv() {
1769
+ const baseUrl = process.env.JIRA_BASE_URL;
1770
+ const email = process.env.JIRA_EMAIL;
1771
+ const token = process.env.JIRA_API_TOKEN;
1772
+ if (!baseUrl || !email || !token) {
1773
+ throw new InfraError(
1774
+ "Jira is not configured \u2014 set JIRA_BASE_URL (https://yourorg.atlassian.net), JIRA_EMAIL, and JIRA_API_TOKEN (create one at id.atlassian.com \u2192 Security \u2192 API tokens)"
1775
+ );
1776
+ }
1777
+ return { baseUrl: baseUrl.replace(/\/+$/, ""), email, token };
1778
+ }
1779
+ function authHeader(cfg) {
1780
+ return `Basic ${Buffer.from(`${cfg.email}:${cfg.token}`).toString("base64")}`;
1781
+ }
1782
+ async function jiraFetch(cfg, path, init) {
1783
+ const res = await fetch(`${cfg.baseUrl}${path}`, {
1784
+ ...init,
1785
+ headers: {
1786
+ Authorization: authHeader(cfg),
1787
+ Accept: "application/json",
1788
+ ...init?.headers ?? {}
1789
+ }
1790
+ });
1791
+ return res;
1792
+ }
1793
+ async function assertIssue(cfg, issueKey) {
1794
+ const res = await jiraFetch(cfg, `/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=summary`);
1795
+ if (res.status === 404) throw new InfraError(`Jira issue ${issueKey} not found on ${cfg.baseUrl}`);
1796
+ if (res.status === 401 || res.status === 403) {
1797
+ throw new InfraError(`Jira rejected the credentials for ${cfg.email} (HTTP ${res.status}) \u2014 check JIRA_API_TOKEN`);
1798
+ }
1799
+ if (!res.ok) throw new InfraError(`Jira issue lookup failed: HTTP ${res.status}`);
1800
+ const body = await res.json();
1801
+ return { key: body.key, summary: body.fields?.summary ?? "" };
1802
+ }
1803
+ async function attachFile(cfg, issueKey, filePath) {
1804
+ const data = await readFile5(filePath);
1805
+ const form = new FormData();
1806
+ form.append("file", new Blob([new Uint8Array(data)]), basename(filePath));
1807
+ const res = await jiraFetch(cfg, `/rest/api/3/issue/${encodeURIComponent(issueKey)}/attachments`, {
1808
+ method: "POST",
1809
+ headers: { "X-Atlassian-Token": "no-check" },
1810
+ body: form
1811
+ });
1812
+ if (!res.ok) {
1813
+ const text = await res.text().catch(() => "");
1814
+ throw new InfraError(`attaching ${basename(filePath)} failed: HTTP ${res.status} ${text.slice(0, 200)}`);
1815
+ }
1816
+ return basename(filePath);
1817
+ }
1818
+ async function addComment(cfg, issueKey, adfBody) {
1819
+ const res = await jiraFetch(cfg, `/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
1820
+ method: "POST",
1821
+ headers: { "Content-Type": "application/json" },
1822
+ body: JSON.stringify({ body: adfBody })
1823
+ });
1824
+ if (!res.ok) {
1825
+ const text = await res.text().catch(() => "");
1826
+ throw new InfraError(`posting the comment failed: HTTP ${res.status} ${text.slice(0, 200)}`);
1827
+ }
1828
+ }
1829
+ async function collectEvidence(outDir) {
1830
+ const candidates = [
1831
+ join9(outDir, "out.mp4"),
1832
+ join9(outDir, "failure.mp4"),
1833
+ join9(outDir, "verify", "verdict.json"),
1834
+ join9(outDir, "verify", "contact-sheet.png"),
1835
+ join9(outDir, "junit.xml"),
1836
+ join9(outDir, "capture", "failure.json"),
1837
+ join9(outDir, "capture", "console.json")
1838
+ ];
1839
+ const files = [];
1840
+ for (const f of candidates) {
1841
+ if (!existsSync4(f)) continue;
1842
+ const s = await stat(f);
1843
+ if (s.size > 95 * 1024 * 1024) {
1844
+ log.warn(`skipping ${basename(f)} (${(s.size / 1e6).toFixed(0)}MB \u2014 larger than Jira's usual attachment ceiling)`);
1845
+ continue;
1846
+ }
1847
+ files.push(f);
1848
+ }
1849
+ if (files.length === 0) {
1850
+ throw new InfraError(`no Playhead artifacts found under ${outDir} \u2014 expected out.mp4/failure.mp4, verify/verdict.json, \u2026`);
1851
+ }
1852
+ let verdict;
1853
+ const verdictPath = join9(outDir, "verify", "verdict.json");
1854
+ if (existsSync4(verdictPath)) verdict = JSON.parse(await readFile5(verdictPath, "utf8"));
1855
+ let failure;
1856
+ const failurePath = join9(outDir, "capture", "failure.json");
1857
+ if (existsSync4(failurePath)) failure = JSON.parse(await readFile5(failurePath, "utf8"));
1858
+ const outcome = failure ? "flow-failed" : verdict?.verdict ?? "unknown";
1859
+ return { outcome, ...verdict ? { verdict } : {}, ...failure ? { failure } : {}, files };
1860
+ }
1861
+ function buildComment(ev, attached) {
1862
+ const text = (t, strong = false) => ({
1863
+ type: "text",
1864
+ text: t,
1865
+ ...strong ? { marks: [{ type: "strong" }] } : {}
1866
+ });
1867
+ const para = (...content2) => ({ type: "paragraph", content: content2 });
1868
+ const bullet = (items) => ({
1869
+ type: "bulletList",
1870
+ content: items.map((i) => ({ type: "listItem", content: [para(text(i))] }))
1871
+ });
1872
+ const content = [];
1873
+ if (ev.outcome === "flow-failed" && ev.failure) {
1874
+ content.push(para(text("Playhead: flow FAILED", true), text(` at step ${ev.failure.stepRef}`)));
1875
+ content.push(para(text(ev.failure.message.split("\n")[0] ?? "")));
1876
+ if (ev.failure.url) content.push(para(text(`Page at failure: ${ev.failure.url}`)));
1877
+ content.push(para(text("The failure clip shows the flow up to the break \u2014 watch failure.mp4.")));
1878
+ } else if (ev.verdict) {
1879
+ const v = ev.verdict;
1880
+ content.push(
1881
+ para(text(`Playhead verdict: ${v.verdict === "publishable" ? "PUBLISHABLE \u2705" : "NOT PUBLISHABLE \u274C"}`, true))
1882
+ );
1883
+ content.push(
1884
+ bullet(
1885
+ v.checks.map((c) => `${c.status === "pass" ? "\u2713" : c.status === "fail" ? "\u2717" : "\xB7"} ${c.id}: ${c.details}`)
1886
+ )
1887
+ );
1888
+ content.push(
1889
+ para(
1890
+ text(
1891
+ `Provenance: playhead ${v.provenance.playheadVersion} \xB7 spec ${v.provenance.specHash.slice(0, 12)}\u2026 \xB7 captured ${v.provenance.capturedAt} \xB7 ${v.provenance.host}${v.signature ? " \xB7 signed" : " \xB7 unsigned"}`
1892
+ )
1893
+ )
1894
+ );
1895
+ } else {
1896
+ content.push(para(text("Playhead render evidence attached.", true)));
1897
+ }
1898
+ content.push(para(text(`Attached: ${attached.join(", ")}`)));
1899
+ return { type: "doc", version: 1, content };
1900
+ }
1901
+ async function reportToJira(issueKey, outDir, cfg = jiraConfigFromEnv()) {
1902
+ const issue = await assertIssue(cfg, issueKey);
1903
+ log.info(`reporting to ${issue.key} (\u201C${issue.summary}\u201D) on ${cfg.baseUrl}`);
1904
+ const ev = await collectEvidence(outDir);
1905
+ const attached = [];
1906
+ for (const f of ev.files) {
1907
+ attached.push(await attachFile(cfg, issueKey, f));
1908
+ log.ok(`attached ${basename(f)}`);
1909
+ }
1910
+ await addComment(cfg, issueKey, buildComment(ev, attached));
1911
+ log.ok(`verdict comment posted to ${issue.key}`);
1912
+ }
1913
+ var init_jira = __esm({
1914
+ "src/integrations/jira.ts"() {
1915
+ "use strict";
1916
+ init_log();
1917
+ init_exit();
1918
+ }
1919
+ });
1920
+
1733
1921
  // src/mcp/server.ts
1734
- import { mkdtemp, writeFile as writeFile7, mkdir as mkdir5, readFile as readFile5 } from "fs/promises";
1922
+ import { mkdtemp, writeFile as writeFile7, mkdir as mkdir5, readFile as readFile6 } from "fs/promises";
1735
1923
 
1736
1924
  // src/shared/version.ts
1737
1925
  import { createRequire } from "module";
@@ -1740,7 +1928,7 @@ var PLAYHEAD_VERSION = createRequire(import.meta.url)("../../package.json").vers
1740
1928
  // src/mcp/server.ts
1741
1929
  init_explore();
1742
1930
  import { tmpdir } from "os";
1743
- import { join as join9, resolve, isAbsolute } from "path";
1931
+ import { join as join10, resolve, isAbsolute } from "path";
1744
1932
  import { z as z2 } from "zod";
1745
1933
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1746
1934
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -2319,20 +2507,7 @@ function round1(n) {
2319
2507
  // src/capture/executor.ts
2320
2508
  init_geometry();
2321
2509
  init_log();
2322
-
2323
- // src/shared/exit.ts
2324
- var EXIT = {
2325
- OK: 0,
2326
- FLOW: 1,
2327
- QUALITY: 2,
2328
- INFRA: 3,
2329
- USAGE: 4
2330
- };
2331
- var FlowError = class extends Error {
2332
- exitCode = EXIT.FLOW;
2333
- };
2334
-
2335
- // src/capture/executor.ts
2510
+ init_exit();
2336
2511
  var SETTLE2 = { idleMs: 300, capMs: 5e3 };
2337
2512
  var TARGET_TIMEOUT_MS = 1e4;
2338
2513
  var EXPECT_TIMEOUT_MS = 1e4;
@@ -5598,7 +5773,7 @@ function buildServer() {
5598
5773
  const server = new McpServer(
5599
5774
  { name: "playhead", version: PLAYHEAD_VERSION },
5600
5775
  {
5601
- instructions: "Playhead turns a spec + a live web app into a verified walkthrough video. To make a demo: (1) call playhead_explore on the running app to get validated locators, (2) write a spec (or call playhead_author), (3) call playhead_render and read the verdict \u2014 fix the spec if it is not publishable. Every locator you use MUST come from an explore catalog."
5776
+ instructions: "Playhead turns a spec + a live web app into a verified walkthrough video. To make a demo: (1) call playhead_explore on the running app to get validated locators, (2) write a spec (or call playhead_author), (3) call playhead_render and read the verdict \u2014 fix the spec if it is not publishable. Every locator you use MUST come from an explore catalog. To attach the result to a ticket, call playhead_jira with the same outDir after rendering."
5602
5777
  }
5603
5778
  );
5604
5779
  const dims = z2.string().regex(/^\d+x\d+$/).default("1280x720");
@@ -5643,7 +5818,7 @@ function buildServer() {
5643
5818
  maxSteps: 40,
5644
5819
  headless: true
5645
5820
  });
5646
- const yaml = await readFile5(path, "utf8");
5821
+ const yaml = await readFile6(path, "utf8");
5647
5822
  return { content: [{ type: "text", text: `Wrote ${steps.length}-step spec to ${path}
5648
5823
 
5649
5824
  ${yaml}` }] };
@@ -5662,7 +5837,7 @@ ${yaml}` }] };
5662
5837
  },
5663
5838
  async ({ specPath, spec, live }) => {
5664
5839
  try {
5665
- const yaml = spec ?? await readFile5(resolve(specPath), "utf8");
5840
+ const yaml = spec ?? await readFile6(resolve(specPath), "utf8");
5666
5841
  const parsed = parseSpec(yaml);
5667
5842
  const steps = parsed.scenes.reduce((n, s) => n + s.steps.length, 0);
5668
5843
  const header = `\u2713 Valid spec "${parsed.title}" \u2014 ${parsed.scenes.length} scenes, ${steps} steps.`;
@@ -5697,8 +5872,8 @@ ${formatValidateResult2(res)}` }],
5697
5872
  await mkdir5(out, { recursive: true });
5698
5873
  let parsedSpec;
5699
5874
  if (spec) {
5700
- const tmp = await mkdtemp(join9(tmpdir(), "playhead-spec-"));
5701
- const p = join9(tmp, "spec.yaml");
5875
+ const tmp = await mkdtemp(join10(tmpdir(), "playhead-spec-"));
5876
+ const p = join10(tmp, "spec.yaml");
5702
5877
  await writeFile7(p, spec);
5703
5878
  parsedSpec = parseSpec(spec);
5704
5879
  } else {
@@ -5714,6 +5889,35 @@ ${formatValidateResult2(res)}` }],
5714
5889
  };
5715
5890
  }
5716
5891
  );
5892
+ server.registerTool(
5893
+ "playhead_jira",
5894
+ {
5895
+ title: "Post render evidence to Jira",
5896
+ description: "Attach a render's evidence to an EXISTING Jira issue and post a verdict comment \u2014 the video (or failure clip), verdict.json, contact sheet, JUnit, and console log, plus a comment carrying the per-check table (or the failing step + error for a flow failure). Run this AFTER playhead_render, pointing outDir at the same directory. Reports to existing issues only (never creates them). Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN in the environment \u2014 Playhead never handles a password.",
5897
+ inputSchema: {
5898
+ issueKey: z2.string().describe("the existing Jira issue key, e.g. PROJ-123"),
5899
+ outDir: z2.string().default("playhead-out").describe("the render output directory (same one passed to playhead_render)")
5900
+ }
5901
+ },
5902
+ async ({ issueKey, outDir }) => {
5903
+ try {
5904
+ const { reportToJira: reportToJira2, collectEvidence: collectEvidence2 } = await Promise.resolve().then(() => (init_jira(), jira_exports));
5905
+ const out = resolve(outDir);
5906
+ await reportToJira2(issueKey, out);
5907
+ const ev = await collectEvidence2(out);
5908
+ return {
5909
+ content: [
5910
+ {
5911
+ type: "text",
5912
+ text: `Posted to ${issueKey}: attached ${ev.files.length} artifact(s) [${ev.files.map((f) => f.split("/").pop()).join(", ")}] and a ${ev.outcome} verdict comment.`
5913
+ }
5914
+ ]
5915
+ };
5916
+ } catch (e) {
5917
+ return { content: [{ type: "text", text: `\u2717 Jira report failed: ${e.message}` }], isError: true };
5918
+ }
5919
+ }
5920
+ );
5717
5921
  return server;
5718
5922
  }
5719
5923
  function renderVerdictReport(verdict, videoPath, manifestPath) {