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/README.md +1 -2
- package/dist/cli/index.js +30 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/mcp/bin.js +225 -21
- package/dist/mcp/bin.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
5701
|
-
const p =
|
|
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) {
|