@thehammer/danx-dashboard-mcp 0.1.278 → 0.1.280
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 -0
- package/dist/design-build.js +259 -0
- package/dist/design-checkout.js +165 -0
- package/dist/design-publish.js +133 -0
- package/dist/design-screenshot.js +98 -0
- package/dist/index.js +33 -0
- package/dist/listen.js +29 -0
- package/dist/tool-access.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
|
|
|
50
50
|
| `danxbot_api_list` | No args. Lists the routes this caller's credential may reach as `{routes: ["METHOD /path"]}` (one bare string per route, exactly what `danxbot_api_spec` takes; a dispatch's own callbacks are in it) — call this first when you don't know the route for what you need to do. |
|
|
51
51
|
| `danxbot_api_spec` | `{endpoints}` (comma-joined `METHOD /path` list, required, min 1). Returns the exact OpenAPI contract for those routes — call this when you know the route but not its exact request/response shape. |
|
|
52
52
|
| `attach_file` | `{card_id, path, replaces?, primary?, filename?, content_type?}` — the server reads the LOCAL file at `path` (absolute, `/tmp/a.png` or `C:\x\a.png`) and uploads it, so its bytes never pass through the agent's context. `replaces: <attachment id>` swaps that attachment's file in place (`PUT /api/issues/:id/attachments/:aid`: same id, name and cover flag) instead of adding one; `primary: true` makes it the card's single cover, atomically with the write. Reply: `{issue: <compact row>, attachment_id, url, thumbnail_url, is_primary}`. Shown to a key holding `cards.edit` (DX-4523); a dispatch profile still receives it only when its catalog selection names it (`slack-agent` and `work`, DX-4386, through `extraTools`). A screenshot of browser-visible work: first `primary: true`, then `replaces` at each milestone. |
|
|
53
|
+
| `design_publish` | `{target, dir, card_id?, board?, title?, summary?, description?}` — builds a design with the TARGET repo's own Vite (resolved from the target's root at run time: this package depends on neither `vite` nor `playwright`) and publishes it as a revision of a Design card (DX-4077). `target` names one of the repo's design targets, read from the dashboard (`GET /api/repos/<repo>`, edited in Settings > Board > General), never from a file; `dir` holds `Design.tsx` under the target's root. The repo is the board's `repo_name` (`GET /api/boards/<board>`). The build runs in `<root>/.danx-design/<build-id>/` (removed afterwards), the page is written to `<tmpdir>/danx-design/<build-id>.html` before the upload, and a 1280x800 screenshot is taken when Playwright resolves from the target or this server's cwd (`screenshot: "none: <reason>"` otherwise, never a failed publish). Without `card_id` it creates the Design card first; a refused revision comes back verbatim with that `card_id`. Shown to a key holding `cards.edit`; opt-in, so a dispatch profile receives it only when its catalog selection names it (none does yet). |
|
|
53
54
|
| `request_permission` | `{permissions, reason}` — asks a person to grant this key ONE MORE permission (see "Asking for one more permission" above). Answers at once with the approval URL and confirm code; the grant is picked up in the background. |
|
|
54
55
|
|
|
55
56
|
There is no dedicated `resource_fields` tool (removed DX-3465 — it was a thin wrapper `danxbot_api` already reaches with no agent-specific behavior earning it its own tool). What an opt-in field TREE may name for one resource (`{resource, description, always, hashes, fields, relations}`) is `danxbot_api({method:"GET", path:"/api/resources/<resource>/fields"})` — install-global, no `board`; `resource: "issue"` is the root, and a relation's own `resource` in the response is what to call this again with, one level deeper.
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4077 - build a design with the TARGET repo's own Vite and fold the result into ONE self-contained HTML document.
|
|
3
|
+
*
|
|
4
|
+
* The package has no `vite` dependency and never will: Vite is resolved from the target's root at run time
|
|
5
|
+
* (`createRequire(<root>/package.json)`), so the design renders with exactly the repo's aliases, Tailwind, fonts and plugins. The
|
|
6
|
+
* build runs INSIDE `<root>/.danx-design/<build-id>/` (Vite refuses an HTML entry outside its root), and that directory is removed
|
|
7
|
+
* in a `finally`, whatever happened.
|
|
8
|
+
*
|
|
9
|
+
* This module carries no validator for the document it produces: that the document is self-contained is checked by exactly ONE
|
|
10
|
+
* validator, the dashboard's own parse5 check on the revision route, whose 400 comes back to the caller verbatim.
|
|
11
|
+
*/
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { mkdir, readdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import { isStrictlyInside, repoRelative } from "./design-checkout.js";
|
|
19
|
+
/** The directory (inside the target root) every build gets its own subdirectory of; the target's `.gitignore` names it. */
|
|
20
|
+
export const BUILD_ROOT_DIR = ".danx-design";
|
|
21
|
+
export const INDEX_HTML = "index.html";
|
|
22
|
+
export const ENTRY_FILE = "entry.tsx";
|
|
23
|
+
export const CSS_FILE = "design.css";
|
|
24
|
+
/** Inside the build dir: Vite's output, and its dependency cache (both removed with the dir). */
|
|
25
|
+
const OUT_DIR = "out";
|
|
26
|
+
const VITE_CACHE_DIR = ".vite";
|
|
27
|
+
/** A build id unique per call, so two builds in one checkout never share a directory. */
|
|
28
|
+
export function newBuildId() {
|
|
29
|
+
return `${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
30
|
+
}
|
|
31
|
+
/** A path as an import / `@import` / `@source` specifier from `fromDir`: relative, forward slashes, `./` when it stays at or below it. */
|
|
32
|
+
function specifier(fromDir, target) {
|
|
33
|
+
const rel = repoRelative(fromDir, target);
|
|
34
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
35
|
+
}
|
|
36
|
+
/** A CSS string literal; a path that would need escaping is refused rather than guessed at. */
|
|
37
|
+
function cssString(value) {
|
|
38
|
+
if (/["\\\r\n]/.test(value))
|
|
39
|
+
throw new Error(`design_publish: the path ${JSON.stringify(value)} cannot be written into a CSS string`);
|
|
40
|
+
return `"${value}"`;
|
|
41
|
+
}
|
|
42
|
+
/** The module specifier of a source file: its extension is dropped, so the target's bundler resolves it the way the app does. */
|
|
43
|
+
function moduleSpecifier(fromDir, target) {
|
|
44
|
+
return specifier(fromDir, target).replace(/\.(tsx?|jsx?|mjs|cjs)$/, "");
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The three files a build is generated from. `entry` imports the harness and the design and calls the harness's `mountDesign`.
|
|
48
|
+
* `design.css` imports the target's own stylesheet and names TWO Tailwind sources, the target's source AND the design directory:
|
|
49
|
+
* with the Vite root outside the app Tailwind skipped the app's own classes, and a design directory under a gitignored path is
|
|
50
|
+
* skipped by automatic detection (the DX-4077 experiment), so both are named.
|
|
51
|
+
*/
|
|
52
|
+
export function generateBuildFiles(input) {
|
|
53
|
+
const { buildDir } = input;
|
|
54
|
+
const indexHtml = `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n` +
|
|
55
|
+
`<title>Design</title>\n</head>\n<body>\n<div id="root"></div>\n<script type="module" src="./${ENTRY_FILE}"></script>\n</body>\n</html>\n`;
|
|
56
|
+
const entry = `// Generated by design_publish; never edited by hand.\n` +
|
|
57
|
+
`import ${JSON.stringify(`./${CSS_FILE}`)};\n` +
|
|
58
|
+
`import { mountDesign } from ${JSON.stringify(moduleSpecifier(buildDir, input.harnessAbs))};\n` +
|
|
59
|
+
`import Design from ${JSON.stringify(moduleSpecifier(buildDir, path.join(input.dirAbs, "Design")))};\n\n` +
|
|
60
|
+
`const el = document.getElementById("root");\n` +
|
|
61
|
+
`if (el === null) throw new Error("design_publish entry: #root is missing");\n` +
|
|
62
|
+
`mountDesign(el, Design);\n`;
|
|
63
|
+
const css = `@import ${cssString(specifier(buildDir, input.stylesheetAbs))};\n` +
|
|
64
|
+
`@source ${cssString(specifier(buildDir, input.rootAbs))};\n` +
|
|
65
|
+
`@source ${cssString(specifier(buildDir, input.dirAbs))};\n`;
|
|
66
|
+
return { indexHtml, entry, css };
|
|
67
|
+
}
|
|
68
|
+
/** `</script` in a script's text would end the element early: `<\/script` is the same text to the JS parser inside a string, regex or template. */
|
|
69
|
+
function escapeScript(js) {
|
|
70
|
+
return js.replace(/<\/(script)/gi, "<\\/$1");
|
|
71
|
+
}
|
|
72
|
+
function refEndsWith(ref, name) {
|
|
73
|
+
const clean = ref.split(/[?#]/)[0];
|
|
74
|
+
return clean === name || clean.endsWith(`/${name}`);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fold the built page's one script and one stylesheet into the HTML: the `<script ... src>` that references the JS file becomes an
|
|
78
|
+
* inline module script (with `</script` escaped), and the `<link rel="stylesheet" href>` that references the CSS file becomes a
|
|
79
|
+
* `<style>`. A tag the build did not write for that file is a failure of this fold and throws; the document is never checked for
|
|
80
|
+
* relative references here (the dashboard's parse5 check owns that).
|
|
81
|
+
*/
|
|
82
|
+
export function inlineAssets(html, js, css) {
|
|
83
|
+
let out = html;
|
|
84
|
+
let scripts = 0;
|
|
85
|
+
out = out.replace(/<script\b[^>]*\bsrc=(["'])([^"']*)\1[^>]*>\s*<\/script>/gi, (tag, _quote, src) => {
|
|
86
|
+
if (!refEndsWith(src, js.name))
|
|
87
|
+
return tag;
|
|
88
|
+
scripts += 1;
|
|
89
|
+
return `<script type="module">\n${escapeScript(js.text)}\n</script>`;
|
|
90
|
+
});
|
|
91
|
+
if (scripts !== 1)
|
|
92
|
+
throw new Error(`design_publish: the built index.html has ${scripts} <script> tags for ${js.name}; expected exactly 1`);
|
|
93
|
+
if (/<\/style/i.test(css.text)) {
|
|
94
|
+
throw new Error(`design_publish: ${css.name} contains "</style" and cannot be inlined into a <style> element`);
|
|
95
|
+
}
|
|
96
|
+
let links = 0;
|
|
97
|
+
out = out.replace(/<link\b[^>]*>/gi, (tag) => {
|
|
98
|
+
if (!/\brel=(["'])stylesheet\1/i.test(tag))
|
|
99
|
+
return tag;
|
|
100
|
+
const href = /\bhref=(["'])([^"']*)\1/i.exec(tag)?.[2];
|
|
101
|
+
if (href === undefined || !refEndsWith(href, css.name))
|
|
102
|
+
return tag;
|
|
103
|
+
links += 1;
|
|
104
|
+
return `<style>\n${css.text}\n</style>`;
|
|
105
|
+
});
|
|
106
|
+
if (links !== 1)
|
|
107
|
+
throw new Error(`design_publish: the built index.html has ${links} stylesheet links for ${css.name}; expected exactly 1`);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
async function listFiles(dir) {
|
|
111
|
+
const found = [];
|
|
112
|
+
const walk = async (current) => {
|
|
113
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
114
|
+
const full = path.join(current, entry.name);
|
|
115
|
+
if (entry.isDirectory())
|
|
116
|
+
await walk(full);
|
|
117
|
+
else
|
|
118
|
+
found.push(full);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
await walk(dir);
|
|
122
|
+
return found.sort();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Read what the build wrote. The output directory must hold exactly `index.html` (Vite keeps its path below the root, so it may sit in
|
|
126
|
+
* a subdirectory), ONE `.js` and ONE `.css`: anything else, such as a second chunk or an emitted asset, fails naming the file.
|
|
127
|
+
*/
|
|
128
|
+
export async function readBuildOutput(outDir) {
|
|
129
|
+
const files = await listFiles(outDir);
|
|
130
|
+
const slots = { html: [], js: [], css: [] };
|
|
131
|
+
const extras = [];
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
const ext = path.extname(file).toLowerCase();
|
|
134
|
+
if (path.basename(file) === INDEX_HTML)
|
|
135
|
+
slots.html.push(file);
|
|
136
|
+
else if (ext === ".js")
|
|
137
|
+
slots.js.push(file);
|
|
138
|
+
else if (ext === ".css")
|
|
139
|
+
slots.css.push(file);
|
|
140
|
+
else
|
|
141
|
+
extras.push(file);
|
|
142
|
+
}
|
|
143
|
+
extras.push(...slots.html.slice(1), ...slots.js.slice(1), ...slots.css.slice(1));
|
|
144
|
+
if (extras.length > 0) {
|
|
145
|
+
const named = extras.map((f) => repoRelative(outDir, f)).sort();
|
|
146
|
+
throw new Error(`design_publish: the build wrote an unexpected file ${named.join(", ")}: the output must be only ${INDEX_HTML}, one .js and one .css`);
|
|
147
|
+
}
|
|
148
|
+
for (const [kind, label] of [["html", INDEX_HTML], ["js", ".js file"], ["css", ".css file"]]) {
|
|
149
|
+
if (slots[kind].length === 0)
|
|
150
|
+
throw new Error(`design_publish: the build wrote no ${label}`);
|
|
151
|
+
}
|
|
152
|
+
const asset = async (file) => ({ name: path.basename(file), text: await readFile(file, "utf8") });
|
|
153
|
+
return { html: await asset(slots.html[0]), js: await asset(slots.js[0]), css: await asset(slots.css[0]) };
|
|
154
|
+
}
|
|
155
|
+
/** Vite as the TARGET repo resolves it from its own root; the package never depends on it. */
|
|
156
|
+
export async function loadTargetVite(rootAbs) {
|
|
157
|
+
let resolved;
|
|
158
|
+
try {
|
|
159
|
+
resolved = createRequire(path.join(rootAbs, "package.json")).resolve("vite");
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
throw new Error(`design_publish: "vite" does not resolve from the target root ${rootAbs} (install the repo's dependencies): ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
return (await import(/* @vite-ignore */ pathToFileURL(resolved).href));
|
|
165
|
+
}
|
|
166
|
+
function resolveInside(base, value) {
|
|
167
|
+
return typeof value === "string" && value !== "" ? path.resolve(base, value) : null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The merged config's guard, run before anything is built. The frontend's own config sets `emptyOutDir: true` and `outDir: "dist"`, so
|
|
171
|
+
* a merge that failed to move them would empty the app's real `dist`: both the output directory and the cache directory must resolve
|
|
172
|
+
* inside the build dir, and the one entry must be exactly the generated page.
|
|
173
|
+
*/
|
|
174
|
+
export function assertConfigContained(merged, rootAbs, buildDir, indexHtml) {
|
|
175
|
+
const build = (merged.build ?? {});
|
|
176
|
+
const outDir = resolveInside(rootAbs, build.outDir);
|
|
177
|
+
const cacheDir = resolveInside(rootAbs, merged.cacheDir);
|
|
178
|
+
if (outDir === null || !isStrictlyInside(buildDir, outDir)) {
|
|
179
|
+
throw new Error(`design_publish: refusing to build: the merged build.outDir (${String(build.outDir)}) is not inside the build dir ${buildDir}`);
|
|
180
|
+
}
|
|
181
|
+
if (cacheDir === null || !isStrictlyInside(buildDir, cacheDir)) {
|
|
182
|
+
throw new Error(`design_publish: refusing to build: the merged cacheDir (${String(merged.cacheDir)}) is not inside the build dir ${buildDir}`);
|
|
183
|
+
}
|
|
184
|
+
const input = build.rollupOptions?.input;
|
|
185
|
+
if (typeof input !== "string" || path.resolve(rootAbs, input) !== indexHtml) {
|
|
186
|
+
throw new Error(`design_publish: refusing to build: the merged build.rollupOptions.input is not the generated ${indexHtml}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build the design: generate the three files in a fresh `<root>/.danx-design/<build-id>/`, load the target's Vite and its config,
|
|
191
|
+
* merge the overrides, guard, build, read the output, fold it into one document. The build dir is removed in a `finally`; a failed
|
|
192
|
+
* cleanup never replaces the error that came first.
|
|
193
|
+
*/
|
|
194
|
+
export async function buildDesign(input) {
|
|
195
|
+
const buildDir = path.join(input.rootAbs, BUILD_ROOT_DIR, input.buildId);
|
|
196
|
+
let failed = false;
|
|
197
|
+
try {
|
|
198
|
+
await mkdir(buildDir, { recursive: true });
|
|
199
|
+
const indexHtml = path.join(buildDir, INDEX_HTML);
|
|
200
|
+
const files = generateBuildFiles({ buildDir, rootAbs: input.rootAbs, stylesheetAbs: input.stylesheetAbs, harnessAbs: input.harnessAbs, dirAbs: input.dirAbs });
|
|
201
|
+
await writeFile(indexHtml, files.indexHtml, "utf8");
|
|
202
|
+
await writeFile(path.join(buildDir, ENTRY_FILE), files.entry, "utf8");
|
|
203
|
+
await writeFile(path.join(buildDir, CSS_FILE), files.css, "utf8");
|
|
204
|
+
const vite = await loadTargetVite(input.rootAbs);
|
|
205
|
+
// "warn": Vite logs info to stdout, which on this stdio MCP server is the protocol channel; warnings and errors go to stderr.
|
|
206
|
+
const loaded = await vite.loadConfigFromFile({ command: "build", mode: "production" }, input.viteConfigAbs, input.rootAbs, "warn");
|
|
207
|
+
if (loaded === null)
|
|
208
|
+
throw new Error(`design_publish: Vite found no config at ${input.viteConfigAbs}`);
|
|
209
|
+
const outDir = path.join(buildDir, OUT_DIR);
|
|
210
|
+
const merged = vite.mergeConfig(loaded.config, {
|
|
211
|
+
// The repo's config is already loaded: the build must not read it a second time.
|
|
212
|
+
configFile: false,
|
|
213
|
+
root: input.rootAbs,
|
|
214
|
+
base: "./",
|
|
215
|
+
logLevel: "warn",
|
|
216
|
+
cacheDir: path.join(buildDir, VITE_CACHE_DIR),
|
|
217
|
+
build: {
|
|
218
|
+
outDir,
|
|
219
|
+
assetsInlineLimit: () => true,
|
|
220
|
+
cssCodeSplit: false,
|
|
221
|
+
modulePreload: false,
|
|
222
|
+
emptyOutDir: false,
|
|
223
|
+
reportCompressedSize: false,
|
|
224
|
+
rollupOptions: { input: indexHtml, output: { inlineDynamicImports: true } },
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
assertConfigContained(merged, input.rootAbs, buildDir, indexHtml);
|
|
228
|
+
await vite.build(merged);
|
|
229
|
+
const built = await readBuildOutput(outDir);
|
|
230
|
+
return { html: inlineAssets(built.html.text, built.js, built.css), buildId: input.buildId };
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
failed = true;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
try {
|
|
238
|
+
await rm(buildDir, { recursive: true, force: true });
|
|
239
|
+
// The parent holds one directory per build in flight: removed when this was the last, left when another build still uses it.
|
|
240
|
+
// rmdir only succeeds on an EMPTY directory, so a concurrent build that has made its own subdirectory makes this fail (ENOTEMPTY) and
|
|
241
|
+
// the parent stays; a build that creates its dir between this rmdir and its own mkdir is safe because mkdir is recursive.
|
|
242
|
+
await rmdir(path.dirname(buildDir)).catch(() => undefined);
|
|
243
|
+
}
|
|
244
|
+
catch (cleanupError) {
|
|
245
|
+
// The error that ended the build is the one to report; a directory that could not be removed is named on stderr.
|
|
246
|
+
if (!failed)
|
|
247
|
+
throw cleanupError;
|
|
248
|
+
console.error(`[danx-dashboard-mcp] design_publish could not remove ${buildDir}: ${cleanupError.message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** `<os.tmpdir()>/danx-design/<build-id>.html`: the finished page, written before the upload so an agent can open it even when the revision is refused. */
|
|
253
|
+
export async function writeLocalHtml(buildId, html) {
|
|
254
|
+
const dir = path.join(os.tmpdir(), "danx-design");
|
|
255
|
+
await mkdir(dir, { recursive: true });
|
|
256
|
+
const file = path.join(dir, `${buildId}.html`);
|
|
257
|
+
await writeFile(file, html, "utf8");
|
|
258
|
+
return file;
|
|
259
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4077 - the checkout side of `design_publish`: which design target the dashboard declared, and whether THIS checkout (the one
|
|
3
|
+
* the design directory lives in) holds what that target names.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here reads a file for a SETTING. The targets arrive from the dashboard (`GET /api/repos/<repo>`, `design_targets`); this
|
|
6
|
+
* module only checks them against the filesystem, which is a fact of a checkout, not a setting: a path may be missing, or the
|
|
7
|
+
* directory may belong to another repo. It never reads `.danxbot/` or any `config.yml`.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
/** The five keys of the one canonical target shape (DX-4077 / CP1: no version field, no repair of a row missing one). */
|
|
15
|
+
export const TARGET_KEYS = ["name", "root", "vite_config", "stylesheet", "harness"];
|
|
16
|
+
export const DESIGN_FILE = "Design.tsx";
|
|
17
|
+
/**
|
|
18
|
+
* The targets out of `GET /api/repos/<repo>`'s body. A body without the array, or a row missing a key, is a fault in what the
|
|
19
|
+
* dashboard answered: it fails loud naming the index and key, and the row is never repaired.
|
|
20
|
+
*/
|
|
21
|
+
export function parseDesignTargets(body, repo) {
|
|
22
|
+
const targets = body?.design_targets;
|
|
23
|
+
if (!Array.isArray(targets)) {
|
|
24
|
+
throw new Error(`design_publish: GET /api/repos/${repo} answered without a design_targets array (a dashboard older than this tool, or a repo it does not serve?)`);
|
|
25
|
+
}
|
|
26
|
+
return targets.map((row, i) => {
|
|
27
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
|
28
|
+
throw new Error(`design_publish: design_targets[${i}] of repo "${repo}" is not an object`);
|
|
29
|
+
}
|
|
30
|
+
for (const key of TARGET_KEYS) {
|
|
31
|
+
const value = row[key];
|
|
32
|
+
if (typeof value !== "string" || value === "") {
|
|
33
|
+
throw new Error(`design_publish: design_targets[${i}] of repo "${repo}" has no "${key}" (a non-empty string is required)`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return row;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** The declared target named `name`, or the refusal an agent can act on: no targets at all, or the declared names. */
|
|
40
|
+
export function pickDesignTarget(targets, name, repo) {
|
|
41
|
+
if (targets.length === 0) {
|
|
42
|
+
throw new Error(`design_publish: repo ${repo} declares no design targets; add one in Settings > Board > General (Design targets)`);
|
|
43
|
+
}
|
|
44
|
+
const found = targets.find((t) => t.name === name);
|
|
45
|
+
if (found === undefined) {
|
|
46
|
+
throw new Error(`design_publish: repo ${repo} declares no design target "${name}"; declared: ${targets.map((t) => `"${t.name}"`).join(", ")}`);
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
/** True when `child` is strictly inside `parent` (both absolute, already real paths). */
|
|
51
|
+
export function isStrictlyInside(parent, child) {
|
|
52
|
+
const rel = path.relative(parent, child);
|
|
53
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
54
|
+
}
|
|
55
|
+
/** `p` relative to `base`, always with forward slashes (the form the dashboard stores repo-relative paths in). */
|
|
56
|
+
export function repoRelative(base, p) {
|
|
57
|
+
return path.relative(base, p).split(path.sep).join("/");
|
|
58
|
+
}
|
|
59
|
+
async function git(repoDir, args) {
|
|
60
|
+
const { stdout } = await execFileAsync("git", ["-C", repoDir, ...args], { windowsHide: true });
|
|
61
|
+
return stdout.trim();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The checkout the design directory lives in: `git -C <dir> rev-parse --show-toplevel` (no shell), a fact of the checkout and not a
|
|
65
|
+
* setting. It resolves a subdirectory, and a `.claude/worktrees/<n>` checkout, to that checkout's own top level.
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveRepoRoot(dir) {
|
|
68
|
+
let top;
|
|
69
|
+
try {
|
|
70
|
+
top = await git(dir, ["rev-parse", "--show-toplevel"]);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
throw new Error(`design_publish: dir "${dir}" is not inside a git checkout: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
return realpath(top);
|
|
76
|
+
}
|
|
77
|
+
/** `git -C <repoRoot> rev-parse HEAD`: the commit the source files are taken from. */
|
|
78
|
+
export async function readHeadCommit(repoRoot) {
|
|
79
|
+
return git(repoRoot, ["rev-parse", "HEAD"]);
|
|
80
|
+
}
|
|
81
|
+
/** The absolute, real path of `dir` (resolved against `cwd` when relative); it must exist and be a directory. */
|
|
82
|
+
export async function resolveDesignDir(dir, cwd) {
|
|
83
|
+
const abs = path.resolve(cwd, dir);
|
|
84
|
+
let real;
|
|
85
|
+
try {
|
|
86
|
+
real = await realpath(abs);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new Error(`design_publish: dir "${dir}" does not exist (resolved to ${abs})`);
|
|
90
|
+
}
|
|
91
|
+
if (!(await stat(real)).isDirectory())
|
|
92
|
+
throw new Error(`design_publish: dir "${dir}" is not a directory`);
|
|
93
|
+
return real;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Check the target's declared paths against the checkout: each of `root` (a directory), `vite_config`, `stylesheet` and `harness`
|
|
97
|
+
* (files) resolves inside the repo root and exists, and the design directory is inside the root and holds `Design.tsx`. Every refusal
|
|
98
|
+
* names the field, the value and the repo, so a checkout that is not this repo fails here.
|
|
99
|
+
*/
|
|
100
|
+
export async function checkTarget(target, repo, repoRoot, dirAbs) {
|
|
101
|
+
const resolveField = async (field, kind) => {
|
|
102
|
+
const value = target[field];
|
|
103
|
+
const refuse = (why) => new Error(`design_publish: target "${target.name}" of repo ${repo}: ${field} "${value}" ${why} (checkout ${repoRoot})`);
|
|
104
|
+
const abs = path.resolve(repoRoot, value);
|
|
105
|
+
if (!isStrictlyInside(repoRoot, abs))
|
|
106
|
+
throw refuse("is outside the repo root");
|
|
107
|
+
let info;
|
|
108
|
+
try {
|
|
109
|
+
info = await stat(abs);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw refuse("does not exist in this checkout");
|
|
113
|
+
}
|
|
114
|
+
if (kind === "directory" ? !info.isDirectory() : !info.isFile())
|
|
115
|
+
throw refuse(`is not a ${kind}`);
|
|
116
|
+
return realpath(abs);
|
|
117
|
+
};
|
|
118
|
+
const rootAbs = await resolveField("root", "directory");
|
|
119
|
+
const viteConfigAbs = await resolveField("vite_config", "file");
|
|
120
|
+
const stylesheetAbs = await resolveField("stylesheet", "file");
|
|
121
|
+
const harnessAbs = await resolveField("harness", "file");
|
|
122
|
+
if (!isStrictlyInside(rootAbs, dirAbs)) {
|
|
123
|
+
throw new Error(`design_publish: dir "${dirAbs}" is not inside the root "${target.root}" of target "${target.name}" (${rootAbs})`);
|
|
124
|
+
}
|
|
125
|
+
const designFile = path.join(dirAbs, DESIGN_FILE);
|
|
126
|
+
try {
|
|
127
|
+
if (!(await stat(designFile)).isFile())
|
|
128
|
+
throw new Error("not a file");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new Error(`design_publish: dir "${dirAbs}" holds no ${DESIGN_FILE}`);
|
|
132
|
+
}
|
|
133
|
+
return { repoRoot, rootAbs, viteConfigAbs, stylesheetAbs, harnessAbs, dirAbs };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Every file under `dir` as `{path, content}`: the path repo-relative with forward slashes, the content strict UTF-8 (a file that is
|
|
137
|
+
* not refuses, naming it; a byte-order mark is kept, never stripped). The server's file-count and size caps answer a 413 for a
|
|
138
|
+
* directory that is too large; this reads what is there and does not guess at them.
|
|
139
|
+
*/
|
|
140
|
+
export async function readSourceFiles(dirAbs, repoRoot) {
|
|
141
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
142
|
+
const files = [];
|
|
143
|
+
const walk = async (current) => {
|
|
144
|
+
for (const entry of (await readdir(current, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
145
|
+
const full = path.join(current, entry.name);
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
await walk(full);
|
|
148
|
+
}
|
|
149
|
+
else if (entry.isFile()) {
|
|
150
|
+
const bytes = await readFile(full);
|
|
151
|
+
try {
|
|
152
|
+
files.push({ path: repoRelative(repoRoot, full), content: decoder.decode(bytes) });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
throw new Error(`design_publish: ${repoRelative(repoRoot, full)} is not valid UTF-8 text; a design directory holds text files only`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
throw new Error(`design_publish: ${repoRelative(repoRoot, full)} is not a plain file or directory; a design directory holds plain files only`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
await walk(dirAbs);
|
|
164
|
+
return files;
|
|
165
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4077 - the `design_publish` flow: build a design against the repo's real components with the repo's own Vite, and publish it as
|
|
3
|
+
* a revision of a Design card.
|
|
4
|
+
*
|
|
5
|
+
* It lives in this package, not behind `danxbot_api`, because it is agent-specific: the build runs where the source is (the agent's
|
|
6
|
+
* own checkout or worktree) and the dashboard holds no connected repo's source and no Vite. Every dashboard call goes through the same
|
|
7
|
+
* client `danxbot_api` uses, so it carries the session's credential, trace and session headers.
|
|
8
|
+
*
|
|
9
|
+
* ORDER, so a failure leaves as little behind as it can: the arguments, then the board and card, then the repo and its targets (the
|
|
10
|
+
* dashboard answers; a refusal comes back verbatim), then the checkout and the build, then the screenshot, and only THEN the card is
|
|
11
|
+
* created and the revision posted. A failed build or screenshot never leaves a card. The two writes are separate calls, so a refused
|
|
12
|
+
* revision leaves a Design card with no revision: that is not hidden or undone, the refusal comes back verbatim WITH the new
|
|
13
|
+
* `card_id`, and a retry with it adds revision 1 to the same card.
|
|
14
|
+
*/
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { buildDesign, newBuildId, writeLocalHtml } from "./design-build.js";
|
|
17
|
+
import { checkTarget, DESIGN_FILE, parseDesignTargets, pickDesignTarget, readHeadCommit, readSourceFiles, repoRelative, resolveDesignDir, resolveRepoRoot, } from "./design-checkout.js";
|
|
18
|
+
import { captureScreenshot } from "./design-screenshot.js";
|
|
19
|
+
import { danxbotApi } from "./handlers.js";
|
|
20
|
+
function fail(message) {
|
|
21
|
+
throw new Error(`design_publish: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
function field(body, name, where) {
|
|
24
|
+
const value = body?.[name];
|
|
25
|
+
if (typeof value !== "string" || value === "")
|
|
26
|
+
fail(`${where} answered without "${name}"`);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
/** The board the card lives on: the card's own (read from it), else the named one, else the server's, else a refusal naming `board`. */
|
|
30
|
+
async function resolveBoard(client, args, deps) {
|
|
31
|
+
if (args.card_id === undefined) {
|
|
32
|
+
const board = args.board ?? deps.serverBoard;
|
|
33
|
+
if (board === undefined)
|
|
34
|
+
fail("no board: this server has none of its own, so name one in `board` (`<repo>:<slug>`)");
|
|
35
|
+
return { ok: true, board };
|
|
36
|
+
}
|
|
37
|
+
const card = await danxbotApi(client, {
|
|
38
|
+
method: "GET",
|
|
39
|
+
path: `/api/issues/${encodeURIComponent(args.card_id)}`,
|
|
40
|
+
query: { fields: { board_id: true } },
|
|
41
|
+
});
|
|
42
|
+
if (!card.ok)
|
|
43
|
+
return card;
|
|
44
|
+
const board = field(card.body, "board_id", `GET /api/issues/${args.card_id}`);
|
|
45
|
+
if (args.board !== undefined && args.board !== board) {
|
|
46
|
+
fail(`card ${args.card_id} lives on board ${board}, not ${args.board}: a revision goes on the card's own board, so omit \`board\` or name ${board}`);
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, board };
|
|
49
|
+
}
|
|
50
|
+
export async function designPublish(client, args, deps) {
|
|
51
|
+
// With `card_id` the card already exists: its title, summary and description are not this call's to set, and silently ignoring them would hide the mistake.
|
|
52
|
+
if (args.card_id !== undefined) {
|
|
53
|
+
const given = ["title", "summary", "description"].filter((k) => args[k] !== undefined);
|
|
54
|
+
if (given.length > 0)
|
|
55
|
+
fail(`${given.map((k) => `\`${k}\``).join(", ")} name the card this call CREATES, so they cannot be combined with \`card_id\`: drop them to add a revision to ${args.card_id}`);
|
|
56
|
+
}
|
|
57
|
+
// No card to add to: it is created here, and a card needs a title and a summary. Refused before any call.
|
|
58
|
+
if (args.card_id === undefined && (args.title === undefined || args.summary === undefined)) {
|
|
59
|
+
fail("`title` and `summary` are required without `card_id` (they name the Design card this creates)");
|
|
60
|
+
}
|
|
61
|
+
const boardOrRefusal = await resolveBoard(client, args, deps);
|
|
62
|
+
if (!boardOrRefusal.ok)
|
|
63
|
+
return boardOrRefusal;
|
|
64
|
+
const { board } = boardOrRefusal;
|
|
65
|
+
// The dashboard owns board -> repo: the repo is read from the board, never parsed out of its id and never from an env var.
|
|
66
|
+
const boardRow = await danxbotApi(client, { method: "GET", path: `/api/boards/${encodeURIComponent(board)}` });
|
|
67
|
+
if (!boardRow.ok)
|
|
68
|
+
return boardRow;
|
|
69
|
+
const repo = field(boardRow.body, "repo_name", `GET /api/boards/${board}`);
|
|
70
|
+
// The targets are a dashboard setting, never a file in the repo.
|
|
71
|
+
const repoRow = await danxbotApi(client, {
|
|
72
|
+
method: "GET",
|
|
73
|
+
path: `/api/repos/${encodeURIComponent(repo)}`,
|
|
74
|
+
query: { fields: { design_targets: true } },
|
|
75
|
+
});
|
|
76
|
+
if (!repoRow.ok)
|
|
77
|
+
return repoRow;
|
|
78
|
+
const target = pickDesignTarget(parseDesignTargets(repoRow.body, repo), args.target, repo);
|
|
79
|
+
const dirAbs = await resolveDesignDir(args.dir, deps.cwd);
|
|
80
|
+
const repoRoot = await resolveRepoRoot(dirAbs);
|
|
81
|
+
const checked = await checkTarget(target, repo, repoRoot, dirAbs);
|
|
82
|
+
const sourceFiles = await readSourceFiles(checked.dirAbs, repoRoot);
|
|
83
|
+
const builtFromCommit = await readHeadCommit(repoRoot);
|
|
84
|
+
const { html, buildId } = await buildDesign({ ...checked, buildId: newBuildId() });
|
|
85
|
+
const localHtmlPath = await writeLocalHtml(buildId, html);
|
|
86
|
+
const shot = await captureScreenshot(html, { rootAbs: checked.rootAbs, cwd: deps.cwd });
|
|
87
|
+
let cardId = args.card_id;
|
|
88
|
+
if (cardId === undefined) {
|
|
89
|
+
const created = await danxbotApi(client, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
path: "/api/issues",
|
|
92
|
+
// `description` is a creation-time seed: the route requires a string.
|
|
93
|
+
body: { type: "Design", plan: "mine", board, title: args.title, summary: args.summary, description: args.description ?? "" },
|
|
94
|
+
board,
|
|
95
|
+
});
|
|
96
|
+
if (!created.ok)
|
|
97
|
+
return { ...created, local_html_path: localHtmlPath };
|
|
98
|
+
cardId = field(created.body?.issue, "id", "POST /api/issues");
|
|
99
|
+
}
|
|
100
|
+
let published;
|
|
101
|
+
try {
|
|
102
|
+
published = await danxbotApi(client, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
path: `/api/issues/${encodeURIComponent(cardId)}/design/revisions`,
|
|
105
|
+
body: {
|
|
106
|
+
target: target.name,
|
|
107
|
+
entry: repoRelative(repoRoot, path.join(checked.dirAbs, DESIGN_FILE)),
|
|
108
|
+
source_files: sourceFiles,
|
|
109
|
+
bundle_html: html,
|
|
110
|
+
built_from_commit: builtFromCommit,
|
|
111
|
+
...(shot.png_base64 === null ? {} : { screenshot_png_base64: shot.png_base64 }),
|
|
112
|
+
},
|
|
113
|
+
board,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
// A network or server fault after the card exists: the card id and the page still matter to the retry.
|
|
118
|
+
throw new Error(`${err.message} (card ${cardId} exists with no revision from this call; retry with card_id "${cardId}"; the page is at ${localHtmlPath})`);
|
|
119
|
+
}
|
|
120
|
+
if (!published.ok)
|
|
121
|
+
return { ...published, card_id: cardId, local_html_path: localHtmlPath };
|
|
122
|
+
const revisionNo = published.body?.revision_no;
|
|
123
|
+
if (typeof revisionNo !== "number")
|
|
124
|
+
fail(`POST /api/issues/${cardId}/design/revisions answered without a revision_no`);
|
|
125
|
+
const body = {
|
|
126
|
+
card_id: cardId,
|
|
127
|
+
revision_no: revisionNo,
|
|
128
|
+
dashboard_url: `${deps.baseUrl.replace(/\/+$/, "")}/board/${cardId}`,
|
|
129
|
+
local_html_path: localHtmlPath,
|
|
130
|
+
screenshot: shot.screenshot,
|
|
131
|
+
};
|
|
132
|
+
return { ok: true, status: published.status, body };
|
|
133
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4077 (operator decision, DX-4083 problem 1856) - the screenshot a design revision carries: a 1280x800 viewport PNG of the built
|
|
3
|
+
* page, taken in a headless Chromium with the network off.
|
|
4
|
+
*
|
|
5
|
+
* The browser is resolved the way Vite is, never as a package dependency: Playwright (or `playwright-core`) from the TARGET root first,
|
|
6
|
+
* then from this server's own working directory (`createRequire(<dir>/package.json)`, never a global lookup). The operator's rule is
|
|
7
|
+
* that "no preview image is a fine degraded mode": no browser, a failed launch, a failed capture or a PNG over 1 MiB all publish
|
|
8
|
+
* WITHOUT a screenshot and say why in the result (`none: <reason>`). That is the one stated degraded mode, reported and never silent,
|
|
9
|
+
* and it never fails the publish.
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
export const SCREENSHOT_WIDTH = 1280;
|
|
14
|
+
export const SCREENSHOT_HEIGHT = 800;
|
|
15
|
+
/** The revision route's cap on a decoded screenshot (`MAX_SCREENSHOT_BYTES`, `src/issues/design-limits.ts`): 1 MiB. */
|
|
16
|
+
export const MAX_SCREENSHOT_BYTES = 1024 * 1024;
|
|
17
|
+
/** How long a loaded page may take to say it mounted (`danx-design:ready`) before the capture goes ahead anyway. */
|
|
18
|
+
const READY_WAIT_MS = 5000;
|
|
19
|
+
/** The message a design's harness posts when it has mounted (`DESIGN_FRAME_READY` in `@danxbot/ui`, restated: this package cannot import it). */
|
|
20
|
+
export const READY_MESSAGE = "danx-design:ready";
|
|
21
|
+
const READY_LISTENER = `window.__danxDesignReady = false; window.addEventListener("message", function (e) { ` +
|
|
22
|
+
`if (e.data && e.data.type === ${JSON.stringify(READY_MESSAGE)}) window.__danxDesignReady = true; });`;
|
|
23
|
+
function none(reason) {
|
|
24
|
+
return { png_base64: null, screenshot: `none: ${reason}` };
|
|
25
|
+
}
|
|
26
|
+
/** One line of an error: its first line, bounded, so a long Playwright launch log never floods the result. */
|
|
27
|
+
function oneLine(err) {
|
|
28
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
29
|
+
const first = text.split("\n")[0].trim();
|
|
30
|
+
return first.length > 200 ? `${first.slice(0, 200)}...` : first;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The first of `playwright` / `playwright-core` that resolves from the target root, else from `cwd`. Returns the loaded module, or null
|
|
34
|
+
* when neither resolves.
|
|
35
|
+
*/
|
|
36
|
+
function resolvePlaywright(rootAbs, cwd) {
|
|
37
|
+
for (const base of [rootAbs, cwd]) {
|
|
38
|
+
const req = createRequire(path.join(base, "package.json"));
|
|
39
|
+
for (const name of ["playwright", "playwright-core"]) {
|
|
40
|
+
let resolved;
|
|
41
|
+
try {
|
|
42
|
+
resolved = req.resolve(name);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
return { module: req(resolved), from: `${name} from ${base}` };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Capture `html` as a 1280x800 viewport PNG (device scale 1, network disabled). The page is `setContent`-ed, then given a short wait
|
|
54
|
+
* for the harness's ready message (the page has loaded either way). The browser is closed in a `finally` on every path.
|
|
55
|
+
*/
|
|
56
|
+
export async function captureScreenshot(html, where) {
|
|
57
|
+
let playwright;
|
|
58
|
+
try {
|
|
59
|
+
playwright = resolvePlaywright(where.rootAbs, where.cwd);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return none(`playwright could not be loaded: ${oneLine(err)}`);
|
|
63
|
+
}
|
|
64
|
+
if (playwright === null) {
|
|
65
|
+
return none(`neither playwright nor playwright-core resolves from the target root ${where.rootAbs} or from ${where.cwd}`);
|
|
66
|
+
}
|
|
67
|
+
let browser;
|
|
68
|
+
try {
|
|
69
|
+
browser = await playwright.module.chromium.launch({ headless: true });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return none(`the browser could not be launched (${playwright.from}): ${oneLine(err)}`);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const context = await browser.newContext({
|
|
76
|
+
viewport: { width: SCREENSHOT_WIDTH, height: SCREENSHOT_HEIGHT },
|
|
77
|
+
deviceScaleFactor: 1,
|
|
78
|
+
offline: true,
|
|
79
|
+
});
|
|
80
|
+
const page = await context.newPage();
|
|
81
|
+
await page.addInitScript(READY_LISTENER);
|
|
82
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
83
|
+
// The page has loaded; a design that never posts ready (it threw, or its harness is older) is still captured as it stands.
|
|
84
|
+
await page.waitForFunction("window.__danxDesignReady === true", undefined, { timeout: READY_WAIT_MS }).catch(() => undefined);
|
|
85
|
+
const png = await page.screenshot({ type: "png" });
|
|
86
|
+
if (png.length > MAX_SCREENSHOT_BYTES) {
|
|
87
|
+
return none(`the screenshot is ${png.length} bytes, over the ${MAX_SCREENSHOT_BYTES} byte (1 MiB) limit`);
|
|
88
|
+
}
|
|
89
|
+
return { png_base64: png.toString("base64"), screenshot: "captured" };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return none(`the screenshot failed: ${oneLine(err)}`);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
// A browser that will not close must not fail a publish whose screenshot is already decided.
|
|
96
|
+
await browser.close().catch((err) => console.error(`[danx-dashboard-mcp] design_publish could not close the browser: ${oneLine(err)}`));
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -106,6 +106,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
106
106
|
import { z } from "zod";
|
|
107
107
|
import { DashboardHttpClient } from "./http-client.js";
|
|
108
108
|
import { planConnect, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
|
|
109
|
+
import { designPublish } from "./design-publish.js";
|
|
109
110
|
import { boundedResultText } from "./result-limit.js";
|
|
110
111
|
import { bodyValueSchema, queryValueSchema } from "./types.js";
|
|
111
112
|
import { termsAcceptanceCallHalt, termsHaltFor } from "./terms-halt.js";
|
|
@@ -632,6 +633,38 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
|
|
|
632
633
|
primary: z.boolean().optional().describe("Make this attachment the card's single cover (lead image)."),
|
|
633
634
|
...boardField,
|
|
634
635
|
}, async (args) => guardedJson(() => attachFile(client, args)));
|
|
636
|
+
// ---------------- design_publish (DX-4077) ----------------
|
|
637
|
+
//
|
|
638
|
+
// Agent-specific, so a dedicated tool (R-25): it builds in the agent's OWN checkout with the target repo's own Vite, which the
|
|
639
|
+
// dashboard cannot do (no Vite in its image, no connected repo's source), and uploads the result. Opt-in (`tool-defs.ts`'s
|
|
640
|
+
// `MCP_SERVER_OPT_IN_TOOLS`) and granted to no profile here: a default tool would add its schema to every default-all role's first
|
|
641
|
+
// turn, and a role with no worktree or toolchain can only fail it. The package takes no `vite` or `playwright` dependency: both are
|
|
642
|
+
// resolved from the target repo at run time (`design-build.ts`, `design-screenshot.ts`).
|
|
643
|
+
strictTool("design_publish", "Build a design against this repo's REAL components with the repo's own Vite and publish it as a revision of a Design card. " +
|
|
644
|
+
"The design is a directory holding `Design.tsx` (a default-exported React component) plus any helpers; it may be a whole " +
|
|
645
|
+
"front-end app, and how to split its states is your call. Without `card_id` this creates the Design card (needs `title` and " +
|
|
646
|
+
"`summary`, and a connected plan: it files the card with plan \"mine\", so connect one with plan_connect first); with `card_id`, the " +
|
|
647
|
+
"next revision goes on that card and `title`, `summary` and `description` are refused. Returns `{card_id, revision_no, dashboard_url, local_html_path, " +
|
|
648
|
+
"screenshot}`: `local_html_path` is the built page, written before the upload so you can open it yourself; `screenshot` is " +
|
|
649
|
+
"`captured` (1280x800) or `none: <reason>` (no Playwright in the repo: the publish still succeeds). Build errors and a " +
|
|
650
|
+
"refused revision (400, 413) come back verbatim; a refusal after the card was created carries its `card_id`: fix the " +
|
|
651
|
+
"design and call again with it. Limits: the frame has no network, so a component that fetches its own data fails inside " +
|
|
652
|
+
"it (use fixture props); React only; targets are edited in Settings > Board > General, never in a file.", {
|
|
653
|
+
target: z.string().min(1).describe("A design target's name, as declared in Settings > Board > General (Design targets)."),
|
|
654
|
+
dir: z
|
|
655
|
+
.string()
|
|
656
|
+
.min(1)
|
|
657
|
+
.describe("The design directory: absolute, or relative to this server's cwd. Inside the target's root, holding `Design.tsx`."),
|
|
658
|
+
card_id: z.string().min(1).optional().describe("Add a revision to this Design card; omit to create one."),
|
|
659
|
+
title: z.string().min(1).optional().describe("The new card's title. Required without `card_id`; refused with it."),
|
|
660
|
+
summary: z.string().min(1).optional().describe("The new card's summary. Required without `card_id`; refused with it."),
|
|
661
|
+
description: z.string().optional().describe("The new card's description; default empty. Refused with `card_id`."),
|
|
662
|
+
board: z
|
|
663
|
+
.string()
|
|
664
|
+
.min(1)
|
|
665
|
+
.optional()
|
|
666
|
+
.describe("Board id `<repo>:<slug>` for the new card; omit for this server's board. With `card_id` the card's own board is used."),
|
|
667
|
+
}, async (args) => guardedJson(() => designPublish(client, args, { serverBoard: config.board, baseUrl: config.baseUrl, cwd: process.cwd() })));
|
|
635
668
|
// ---------------- request_permission (DX-4434, parent DX-4432) ----------------
|
|
636
669
|
strictTool("request_permission", "Ask a person to grant this key ONE MORE permission. Call it when a call was refused `403 forbidden` naming a " +
|
|
637
670
|
"`permission`, or when you know you need one more. It returns AT ONCE with `{state, approvalUrl, confirmCode, " +
|
package/dist/listen.js
CHANGED
|
@@ -79,6 +79,11 @@ function isRecord(value) {
|
|
|
79
79
|
function isCappedText(value) {
|
|
80
80
|
return isRecord(value) && typeof value.text === "string" && typeof value.truncated === "boolean";
|
|
81
81
|
}
|
|
82
|
+
/** DX-4078 - hand-copied from `DESIGN_DECISION_OUTCOMES` (`src/issues/view-types.ts`): this published package cannot import danxbot source. */
|
|
83
|
+
const DESIGN_OUTCOMES = ["approved", "rejected", "changes_requested"];
|
|
84
|
+
function isDesignOutcome(value) {
|
|
85
|
+
return typeof value === "string" && DESIGN_OUTCOMES.includes(value);
|
|
86
|
+
}
|
|
82
87
|
/**
|
|
83
88
|
* DX-3880 — the part `solution_answered` and `problem_answer_changed` share: the
|
|
84
89
|
* decision's `outcome` and the either/or body it implies. A rejection (the
|
|
@@ -188,6 +193,20 @@ export function invalidEventReason(value) {
|
|
|
188
193
|
if (typeof d.review_url !== "string")
|
|
189
194
|
return "detail.review_url is not a string";
|
|
190
195
|
return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
|
|
196
|
+
// DX-4078: the operator's verdict on a design revision. An outcome this listener does not know is refused, never rendered as the bare
|
|
197
|
+
// kind line, which would name no outcome and no note and leave the agent to re-read the card. `rejected` and `changes_requested` always
|
|
198
|
+
// carry their note (the table CHECK); `approved` may carry none.
|
|
199
|
+
case "design_decided": {
|
|
200
|
+
if (typeof d.decision_id !== "number")
|
|
201
|
+
return "detail.decision_id is not a number";
|
|
202
|
+
if (typeof d.revision_no !== "number")
|
|
203
|
+
return "detail.revision_no is not a number";
|
|
204
|
+
if (!isDesignOutcome(d.outcome))
|
|
205
|
+
return 'detail.outcome is not "approved", "rejected" or "changes_requested"';
|
|
206
|
+
if (d.note === null)
|
|
207
|
+
return d.outcome === "approved" ? null : `detail.note (the ${d.outcome} reason) is null`;
|
|
208
|
+
return isCappedText(d.note) ? null : "detail.note is not null or capped text";
|
|
209
|
+
}
|
|
191
210
|
default:
|
|
192
211
|
return null;
|
|
193
212
|
}
|
|
@@ -266,6 +285,16 @@ function describe(event) {
|
|
|
266
285
|
// DX-4622: the agent must act on the review, so the line names who, where, and what to do next.
|
|
267
286
|
case "pull_request_changes_requested":
|
|
268
287
|
return `${who}: ${d.reviewer} requested changes on the pull request (${d.review_url})${d.excerpt.text === "" ? "" : `: ${quoted(d.excerpt)}`}. Address the review, push, and reply on the pull request`;
|
|
288
|
+
// DX-4078: names the outcome, the revision and the note, and what the agent does next, so it never has to re-read the card to learn the verdict.
|
|
289
|
+
case "design_decided": {
|
|
290
|
+
const note = d.note;
|
|
291
|
+
const revision = `revision ${d.revision_no} of the design`;
|
|
292
|
+
if (d.outcome === "approved")
|
|
293
|
+
return `${who} APPROVED ${revision}${note === null ? "" : ` — note: ${quoted(note)}`}. The design is closed; build from it`;
|
|
294
|
+
if (d.outcome === "rejected")
|
|
295
|
+
return `${who} REJECTED ${revision} — reason: ${quoted(note)}. The design is closed; do not continue it`;
|
|
296
|
+
return `${who} requested changes on ${revision} — ${quoted(note)}. Publish a new revision that addresses it`;
|
|
297
|
+
}
|
|
269
298
|
default:
|
|
270
299
|
// A kind newer than this listener still produces a line: an event the
|
|
271
300
|
// agent is never told about is exactly the failure this reader prevents.
|
package/dist/tool-access.js
CHANGED
|
@@ -43,6 +43,10 @@ export const TOOL_ACCESS = {
|
|
|
43
43
|
danxbot_api_spec: BOOTSTRAP,
|
|
44
44
|
// `POST /api/issues/:id/attachments`
|
|
45
45
|
attach_file: needs("cards.edit"),
|
|
46
|
+
// DX-4077 - builds in the agent's own checkout, then `POST /api/issues/:id/design/revisions` (`cards.edit`, DX-4076). The routes it
|
|
47
|
+
// reads on the way (`GET /api/boards/:id`, `GET /api/repos/:id`, `GET /api/issues/:id`) and the card it creates are separate
|
|
48
|
+
// checks on the dashboard whose refusals come back verbatim.
|
|
49
|
+
design_publish: needs("cards.edit"),
|
|
46
50
|
// DX-4233 - the plugin's relay waits on the session's plan events here: it opens the listener ticket
|
|
47
51
|
// (`POST /api/plan-sessions/me/stream-ticket`) and heartbeats (`.../me/heartbeat`), both `plans.view`.
|
|
48
52
|
plan_events_wait: operatorSessionNeeds("plans.view"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.280",
|
|
4
4
|
"description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|