@reqvire-org/ads 0.3.12 → 0.3.13
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 +20 -1
- package/dist/capture/worker.mjs +38 -0
- package/dist/capture-store.mjs +123 -0
- package/dist/capture.mjs +117 -0
- package/dist/config.mjs +6 -1
- package/dist/mcp/context.mjs +1 -1
- package/dist/mcp/mutations.mjs +25 -5
- package/dist/mcp/server.mjs +62 -15
- package/dist/scenarios.mjs +9 -3
- package/dist/showcase/client.bundle.js +18 -18
- package/dist/showcase/style.css +2 -0
- package/dist/skills/ads-break-ui/SKILL.md +6 -1
- package/dist/skills/ads-break-ui/references/captures.md +147 -0
- package/dist/skills/ads-break-ui/references/guarded-editing.md +58 -0
- package/dist/skills/ads-break-ui/references/scenarios.md +5 -0
- package/dist/skills/ads-designer/SKILL.md +6 -1
- package/dist/skills/ads-designer/references/captures.md +147 -0
- package/dist/skills/ads-designer/references/guarded-editing.md +58 -0
- package/dist/skills/ads-designer/references/scenarios.md +5 -0
- package/dist/skills/ads-import/SKILL.md +6 -1
- package/dist/skills/ads-import/references/captures.md +147 -0
- package/dist/skills/ads-import/references/guarded-editing.md +58 -0
- package/dist/skills/ads-import/references/scenarios.md +5 -0
- package/dist/skills/ads-migrate/SKILL.md +6 -1
- package/dist/skills/ads-migrate/references/captures.md +147 -0
- package/dist/skills/ads-migrate/references/guarded-editing.md +58 -0
- package/dist/skills/ads-migrate/references/scenarios.md +5 -0
- package/dist/skills/ads-motion/SKILL.md +6 -1
- package/dist/skills/ads-motion/references/captures.md +147 -0
- package/dist/skills/ads-motion/references/guarded-editing.md +58 -0
- package/dist/skills/ads-motion/references/scenarios.md +5 -0
- package/dist/verification/browser.mjs +8 -3
- package/dist/verification/playwright-reporter.cjs +23 -8
- package/dist/verification.mjs +137 -33
- package/docs/ADS.md +5 -0
- package/docs/ARCHITECTURE.md +39 -2
- package/docs/BEHAVIORS.md +57 -3
- package/docs/CHANGELOG.md +17 -0
- package/docs/GUIDE.md +48 -1
- package/docs/MCP.md +49 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,25 @@ ADS supports **React and native Preact** with the same architecture guards and
|
|
|
19
19
|
MCP preview flow. The empty starter uses React; this repository’s working example
|
|
20
20
|
uses Preact. See [implementation support](docs/GUIDE.md#implementation-support).
|
|
21
21
|
|
|
22
|
+
## What ADS does
|
|
23
|
+
|
|
24
|
+
- **Inspect** components, patterns, controllers, tokens and their owning source
|
|
25
|
+
through focused MCP tools.
|
|
26
|
+
- **Preview** real UI through MCP Apps and the local showcase, using the same
|
|
27
|
+
project-owned scenarios and fixtures.
|
|
28
|
+
- **Edit with guards** using owner patches, coupled transactions and isolated
|
|
29
|
+
dry runs that validate architecture, lint and registered project checks.
|
|
30
|
+
- **Compare visual changes** with on-demand screenshots of current source and
|
|
31
|
+
proposed candidates, exposed as MCP image resources.
|
|
32
|
+
- **Verify behavior** through project-owned guarantees and registered tests,
|
|
33
|
+
with progress, timings and evidence tied to current source.
|
|
34
|
+
- **Guide agents** with prompts and portable skills for building, importing,
|
|
35
|
+
migrating, motion and UI stress testing.
|
|
36
|
+
|
|
37
|
+
See [using ADS through MCP](docs/GUIDE.md#use-ads-through-mcp) for the workflow and
|
|
38
|
+
[visual captures](docs/MCP.md#screenshots-and-candidate-comparison) for screenshot
|
|
39
|
+
setup and comparison.
|
|
40
|
+
|
|
22
41
|
## Set up your project
|
|
23
42
|
|
|
24
43
|
Run **inside your project**, where `package.json` lives. Requires Node 22.12 or newer.
|
|
@@ -99,7 +118,7 @@ they keep source writes behind the same guards. See
|
|
|
99
118
|
|
|
100
119
|
The server exposes public components/patterns, token values,
|
|
101
120
|
source guides, controllers/hooks, architecture checks, behavioral verification,
|
|
102
|
-
workflow prompts, and packaged ADS skills.
|
|
121
|
+
on-demand screenshots, workflow prompts, and packaged ADS skills.
|
|
103
122
|
It supports older MCP clients and the newer Skills protocol.
|
|
104
123
|
|
|
105
124
|
Private controllers stay beside their component; shared controllers have their own
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { openPreviewScenario } from "../verification/browser.mjs";
|
|
5
|
+
|
|
6
|
+
// Runs only as an owned child, using the receiving project's browser dependency.
|
|
7
|
+
const request = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
|
8
|
+
const playwright = createRequire(import.meta.url)(request.playwright);
|
|
9
|
+
let browser;
|
|
10
|
+
try {
|
|
11
|
+
browser = await playwright[request.settings.browser].launch({ headless: true, ...(request.settings.channel ? { channel: request.settings.channel } : {}) });
|
|
12
|
+
const images = [];
|
|
13
|
+
for (const [index, app] of request.apps.entries()) {
|
|
14
|
+
const context = await browser.newContext({ viewport: app.viewport, deviceScaleFactor: 1, colorScheme: app.theme });
|
|
15
|
+
try {
|
|
16
|
+
const page = await context.newPage(), timeout = request.settings.timeoutMs;
|
|
17
|
+
const frame = await openPreviewScenario(page, app.url, app.scenario, { timeout });
|
|
18
|
+
await frame.locator("html").waitFor({ state: "visible", timeout });
|
|
19
|
+
await frame.waitForFunction(theme => document.documentElement.dataset.theme === theme, app.theme, { timeout });
|
|
20
|
+
await frame.evaluate(async () => {
|
|
21
|
+
await document.fonts.ready;
|
|
22
|
+
await Promise.all([...document.images].map(image => image.decode().catch(() => {})));
|
|
23
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
24
|
+
});
|
|
25
|
+
const actualViewport = await frame.evaluate(() => ({ width: innerWidth, height: innerHeight }));
|
|
26
|
+
if (actualViewport.width !== app.viewport.width || actualViewport.height !== app.viewport.height) throw new Error(`App viewport differs from requested capture settings: ${JSON.stringify(actualViewport)}`);
|
|
27
|
+
const target = frame.locator(app.selector);
|
|
28
|
+
if (await target.count() !== 1) throw new Error(`Capture target ${app.target ?? "widget"} must resolve to exactly one element.`);
|
|
29
|
+
await target.waitFor({ state: "visible", timeout });
|
|
30
|
+
const bounds = await target.boundingBox();
|
|
31
|
+
if (!bounds || bounds.width * bounds.height > 8 * 1024 * 1024) throw new Error("Capture target is empty or exceeds 8 megapixels.");
|
|
32
|
+
await target.screenshot({ path: path.join(request.output, `${index}.png`), type: "png", animations: app.animations, caret: "hide", scale: "css", timeout });
|
|
33
|
+
if (await frame.locator("html").getAttribute("data-ads-preview-state") !== "ready") throw new Error("The App stopped being ready during capture.");
|
|
34
|
+
images.push({ index, scenario: app.scenario, actualViewport, capturedAt: new Date().toISOString() });
|
|
35
|
+
} finally { await context.close(); }
|
|
36
|
+
}
|
|
37
|
+
writeFileSync(path.join(request.output, "report.json"), JSON.stringify({ schemaVersion: 1, runId: request.runId, images }));
|
|
38
|
+
} finally { await browser?.close(); }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
export const CAPTURES_URI = "ads://project/captures";
|
|
7
|
+
export const captureLimits = { selections: 8, imageBytes: 4 * 1024 * 1024, pixels: 8 * 1024 * 1024, storeBytes: 256 * 1024 * 1024, pairBytes: 12 * 1024 * 1024 };
|
|
8
|
+
export const captureSpec = z.object({
|
|
9
|
+
scenario: z.string().min(1).max(256), target: z.string().regex(/^[a-zA-Z0-9][\w.-]{0,79}$/).optional(),
|
|
10
|
+
theme: z.enum(["light", "dark"]).default("light"),
|
|
11
|
+
viewport: z.object({ width: z.number().int().min(100).max(4096), height: z.number().int().min(100).max(4096) }).strict().default({ width: 1280, height: 800 }),
|
|
12
|
+
animations: z.enum(["disabled", "allow"]).default("disabled"),
|
|
13
|
+
}).strict().refine(value => value.viewport.width * value.viewport.height <= captureLimits.pixels, "Capture viewport exceeds 8 megapixels.");
|
|
14
|
+
export const screenshotFields = { screenshot: z.boolean().optional(), captures: z.array(captureSpec).min(1).max(captureLimits.selections).optional() };
|
|
15
|
+
export const captureKey = ({ scenario, target }) => createHash("sha256").update(JSON.stringify([scenario, target ?? null])).digest("hex");
|
|
16
|
+
export const captureUri = (key, slot) => `ads://captures/${key}/${slot}`;
|
|
17
|
+
const missing = message => Object.assign(new Error(message), { code: "ADS_CAPTURE_NOT_FOUND" });
|
|
18
|
+
const validTarget = (record, scenarios, revision) => scenarios.some(scenario => scenario.id === record.scenario && (!record.target || Object.hasOwn(scenario.captureTargets ?? {}, record.target))) || record.proposedTarget === true && record.candidate?.baseRevision === revision;
|
|
19
|
+
const summary = image => { if (!image) return null; const { blob: _blob, ...metadata } = image; return metadata; };
|
|
20
|
+
|
|
21
|
+
/** Two slots in one atomically replaced file: no revision-addressed image history. */
|
|
22
|
+
export function createCaptureStore(root) {
|
|
23
|
+
root = path.resolve(root);
|
|
24
|
+
const directory = path.join(root, ".ds/captures"), cache = new Map();
|
|
25
|
+
function safeDirectory(create = false) {
|
|
26
|
+
for (const file of [path.join(root, ".ds"), directory]) {
|
|
27
|
+
if (!existsSync(file)) { if (!create) return false; mkdirSync(file); }
|
|
28
|
+
if (!lstatSync(file).isDirectory() || realpathSync(file) !== file) throw new Error("Capture storage must use ordinary project .ds/captures directories.");
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
function files() { return safeDirectory() ? readdirSync(directory).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).sort() : []; }
|
|
33
|
+
function read(key) {
|
|
34
|
+
if (!safeDirectory()) return null;
|
|
35
|
+
const file = path.join(directory, `${key}.json`);
|
|
36
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
37
|
+
if (!stat) return null;
|
|
38
|
+
if (!stat.isFile() || stat.nlink !== 1 || realpathSync(file) !== file || stat.size > captureLimits.pairBytes) throw new Error("Invalid or oversized capture pair file.");
|
|
39
|
+
const signature = `${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
|
|
40
|
+
if (cache.get(key)?.signature === signature) return cache.get(key).value;
|
|
41
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
42
|
+
if (value.schemaVersion !== 1 || value.key !== key || captureKey(value) !== key || !value.current && !value.candidate || [value.current, value.candidate].filter(Boolean).some(image => typeof image.blob !== "string" || image.blob.length > Math.ceil(captureLimits.imageBytes / 3) * 4 || image.mimeType !== "image/png")) throw new Error("Invalid capture pair schema.");
|
|
43
|
+
cache.set(key, { signature, value });
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function remove(key) { rmSync(path.join(directory, `${key}.json`), { force: true }); cache.delete(key); }
|
|
47
|
+
function commit(records) {
|
|
48
|
+
safeDirectory(true);
|
|
49
|
+
const lock = path.join(directory, ".publish-lock");
|
|
50
|
+
function acquire() { writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: "wx" }); }
|
|
51
|
+
try { acquire(); }
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code !== "EEXIST") throw error;
|
|
54
|
+
const stat = lstatSync(lock);
|
|
55
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024) throw new Error("Invalid capture publication lock.");
|
|
56
|
+
const owner = JSON.parse(readFileSync(lock, "utf8"));
|
|
57
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1) throw new Error("Invalid capture publication owner.");
|
|
58
|
+
let alive = true;
|
|
59
|
+
try { process.kill(owner.pid, 0); } catch (error) { if (error.code === "ESRCH") alive = false; }
|
|
60
|
+
if (alive) throw new Error("Another capture publication is in progress; retry after it finishes.");
|
|
61
|
+
rmSync(lock); acquire();
|
|
62
|
+
}
|
|
63
|
+
const staged = [], written = [];
|
|
64
|
+
try {
|
|
65
|
+
let total = files().reduce((sum, file) => sum + lstatSync(path.join(directory, file)).size, 0);
|
|
66
|
+
for (const record of records) {
|
|
67
|
+
if (record.key !== captureKey(record)) throw new Error("Capture key does not match its scenario/target.");
|
|
68
|
+
const file = path.join(directory, `${record.key}.json`), content = JSON.stringify(record), bytes = Buffer.byteLength(content);
|
|
69
|
+
if (bytes > captureLimits.pairBytes) throw new Error("Capture pair exceeds 12 MiB.");
|
|
70
|
+
const old = read(record.key), previous = old ? readFileSync(file) : null;
|
|
71
|
+
total += bytes - (previous?.length ?? 0);
|
|
72
|
+
if (total > captureLimits.storeBytes) throw new Error("Capture storage exceeds 256 MiB. Clear unneeded targets with ads_capture_clear before capturing again.");
|
|
73
|
+
const temporary = path.join(directory, `.capture-${randomUUID()}`);
|
|
74
|
+
writeFileSync(temporary, content);
|
|
75
|
+
staged.push({ file, temporary, previous, key: record.key });
|
|
76
|
+
}
|
|
77
|
+
for (const entry of staged) { renameSync(entry.temporary, entry.file); cache.delete(entry.key); written.push(entry); }
|
|
78
|
+
} catch (error) {
|
|
79
|
+
for (const entry of written.reverse()) { if (entry.previous) writeFileSync(entry.file, entry.previous); else rmSync(entry.file, { force: true }); cache.delete(entry.key); }
|
|
80
|
+
throw error;
|
|
81
|
+
} finally { for (const entry of staged) rmSync(entry.temporary, { force: true }); rmSync(lock, { force: true }); }
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
commit,
|
|
85
|
+
resourceUris: () => files().flatMap(file => { try { const record = read(file.slice(0, -5)); return ["current", "candidate"].filter(slot => record[slot]).map(slot => captureUri(record.key, slot)); } catch { return []; } }),
|
|
86
|
+
fingerprint: () => files().map(file => { const stat = lstatSync(path.join(directory, file)); return `${file}:${stat.mtimeMs}:${stat.size}`; }).join("\n"),
|
|
87
|
+
list(scenarios, revision, { prune = true } = {}) {
|
|
88
|
+
const captures = [], diagnostics = [];
|
|
89
|
+
for (const file of files()) {
|
|
90
|
+
const key = file.slice(0, -5);
|
|
91
|
+
try {
|
|
92
|
+
const record = read(key);
|
|
93
|
+
if (!validTarget(record, scenarios, revision) && prune) { remove(key); continue; }
|
|
94
|
+
captures.push({ key, scenario: record.scenario, ...(record.target ? { target: record.target } : {}), current: record.current ? { ...summary(record.current), resourceUri: captureUri(key, "current"), stale: record.current.sourceRevision !== revision } : null, ...(!record.current ? { currentAbsent: "This scenario/target is new in the candidate; no baseline image exists." } : {}), candidate: record.candidate ? { ...summary(record.candidate), resourceUri: captureUri(key, "candidate"), stale: record.candidate.baseRevision !== revision } : null });
|
|
95
|
+
} catch (error) { diagnostics.push({ key, message: error.message }); }
|
|
96
|
+
}
|
|
97
|
+
return { captures, diagnostics, limits: captureLimits };
|
|
98
|
+
},
|
|
99
|
+
readResource(uri, scenarios, revision, { allowUnknownTargets = false } = {}) {
|
|
100
|
+
const match = /^ads:\/\/captures\/([a-f0-9]{64})\/(current|candidate)$/.exec(uri);
|
|
101
|
+
if (!match) throw missing("Unknown screenshot URI. Discover captures through ads://project/captures.");
|
|
102
|
+
const record = read(match[1]), slot = match[2];
|
|
103
|
+
if (!record || !allowUnknownTargets && !validTarget(record, scenarios, revision) || !record[slot]) throw missing("This capture slot is absent or was replaced/cleared. Request a new capture with ads_capture or a screenshot-enabled dry run.");
|
|
104
|
+
return { uri, mimeType: "image/png", blob: record[slot].blob, _meta: { ...summary(record[slot]), stale: (slot === "candidate" ? record[slot].baseRevision : record[slot].sourceRevision) !== revision } };
|
|
105
|
+
},
|
|
106
|
+
clear({ scenario, target, candidateOnly = false }) {
|
|
107
|
+
const key = captureKey({ scenario, target }), record = read(key);
|
|
108
|
+
if (!record) return { cleared: false };
|
|
109
|
+
if (candidateOnly && record.current) { commit([{ ...record, candidate: null }]); return { cleared: true, key, slots: ["candidate"] }; }
|
|
110
|
+
remove(key); return { cleared: true, key, slots: ["current", "candidate"] };
|
|
111
|
+
},
|
|
112
|
+
promote(revision, scenarios) {
|
|
113
|
+
const records = [];
|
|
114
|
+
for (const file of files()) {
|
|
115
|
+
const record = read(file.slice(0, -5));
|
|
116
|
+
if (!validTarget(record, scenarios, revision)) { remove(record.key); continue; }
|
|
117
|
+
if (record.candidate?.sourceRevision === revision) records.push({ ...record, proposedTarget: undefined, current: { ...record.candidate, baseRevision: undefined, proposal: undefined }, candidate: null });
|
|
118
|
+
}
|
|
119
|
+
if (records.length) commit(records);
|
|
120
|
+
return records.map(record => record.key);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/capture.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
import { loadConfig } from "./config.mjs";
|
|
8
|
+
import { inspectScenarios } from "./scenarios.mjs";
|
|
9
|
+
import { previewSourceRevision } from "./preview-inputs.mjs";
|
|
10
|
+
import { executeDevelopmentCommand } from "./verification.mjs";
|
|
11
|
+
import { captureKey, captureSpec, captureLimits, createCaptureStore } from "./capture-store.mjs";
|
|
12
|
+
|
|
13
|
+
export function captureSelections(values) {
|
|
14
|
+
if (!Array.isArray(values) || !values.length || values.length > captureLimits.selections) throw new Error("Select 1–8 exact scenarios/targets with captures:[{scenario,target?,theme?,viewport?}]. Discover IDs with ads_scenarios.");
|
|
15
|
+
const selections = values.map(value => captureSpec.parse(value));
|
|
16
|
+
if (new Set(selections.map(captureKey)).size !== selections.length) throw new Error("Select each scenario/target once; settings replace that target's slots rather than creating extra history.");
|
|
17
|
+
return selections;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createCaptureController(root) {
|
|
21
|
+
root = path.resolve(root);
|
|
22
|
+
const store = createCaptureStore(root);
|
|
23
|
+
let active, progress;
|
|
24
|
+
return {
|
|
25
|
+
store,
|
|
26
|
+
status: () => ({ running: !!active, ...(progress ? { progress: { ...progress, elapsedMs: Date.now() - progress.startedAt } } : {}) }),
|
|
27
|
+
async close() { active?.abort(); await active?.completion; },
|
|
28
|
+
async run({ captures, candidateRoot, proposal, publish = true } = {}, signal, notifyProgress) {
|
|
29
|
+
if (active) throw new Error("A screenshot capture is already running. Wait for ads_status.captures.running to clear.");
|
|
30
|
+
const selections = captureSelections(captures);
|
|
31
|
+
const controller = new AbortController(); active = controller;
|
|
32
|
+
signal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
33
|
+
let finish; controller.completion = new Promise(resolve => { finish = resolve; });
|
|
34
|
+
const hosts = [];
|
|
35
|
+
let temporary;
|
|
36
|
+
const startedAt = Date.now();
|
|
37
|
+
const update = (stage = progress?.stage ?? "preparing", completed = progress?.completed ?? 0, total = progress?.total ?? selections.length * (candidateRoot ? 2 : 1)) => {
|
|
38
|
+
progress = { profile: "screenshots", stage, startedAt, elapsedMs: Date.now() - startedAt, completed, total, active: [], message: `Screenshots: ${stage}; ${completed}/${total} images complete; ${Date.now() - startedAt} ms elapsed.` };
|
|
39
|
+
notifyProgress?.(progress);
|
|
40
|
+
};
|
|
41
|
+
const heartbeat = setInterval(update, 5000); heartbeat.unref();
|
|
42
|
+
try {
|
|
43
|
+
update();
|
|
44
|
+
const options = loadConfig(root), require = createRequire(path.join(root, "package.json"));
|
|
45
|
+
let playwright;
|
|
46
|
+
for (const name of ["@playwright/test", "playwright"]) { try { playwright = require.resolve(name); break; } catch { /* Try the other supported project-installed entry. */ } }
|
|
47
|
+
if (!playwright) throw new Error("Screenshots require project-local @playwright/test or playwright and its installed browser. ADS installs neither. Install/configure these in the connected project, then retry; mcp.capture selects browser/channel/timeoutMs.");
|
|
48
|
+
const projects = [root, ...(candidateRoot ? [path.resolve(candidateRoot)] : [])].map(directory => {
|
|
49
|
+
const options = loadConfig(directory), registry = inspectScenarios(options);
|
|
50
|
+
if (registry.diagnostics.length) throw new Error(`Scenario registration failed: ${registry.diagnostics[0].message}`);
|
|
51
|
+
const apps = selections.map((selection, selectionIndex) => {
|
|
52
|
+
const scenario = registry.scenarios.find(scenario => scenario.id === selection.scenario);
|
|
53
|
+
if ((!scenario || selection.target && !Object.hasOwn(scenario.captureTargets ?? {}, selection.target)) && directory === root && candidateRoot) return null;
|
|
54
|
+
if (!scenario) throw new Error(`Unknown scenario ${selection.scenario}. Discover exact IDs with ads_scenarios.`);
|
|
55
|
+
if (selection.target && !Object.hasOwn(scenario.captureTargets ?? {}, selection.target)) throw new Error(`Unknown capture target ${selection.target} in ${selection.scenario}. Discover captureTargets with ads_scenarios; omit target for the full widget viewport.`);
|
|
56
|
+
return { ...selection, selectionIndex, selector: selection.target ? scenario.captureTargets[selection.target] : "html", scenarioFile: scenario.file, fixtureFile: scenario.fixture };
|
|
57
|
+
}).filter(Boolean);
|
|
58
|
+
return { options, revision: previewSourceRevision(options), apps };
|
|
59
|
+
});
|
|
60
|
+
const outputRoot = path.join(root, ".ds");
|
|
61
|
+
mkdirSync(outputRoot, { recursive: true });
|
|
62
|
+
if (realpathSync(outputRoot) !== outputRoot) throw new Error("Capture work output cannot resolve through a symlink.");
|
|
63
|
+
for (const entry of readdirSync(outputRoot)) if (/^capture-work-[a-zA-Z0-9]+$/.test(entry)) {
|
|
64
|
+
const work = path.join(outputRoot, entry), ownerFile = path.join(work, "owner.json");
|
|
65
|
+
try {
|
|
66
|
+
if (!lstatSync(work).isDirectory() || realpathSync(work) !== work || realpathSync(ownerFile) !== ownerFile || lstatSync(ownerFile).size > 1024) continue;
|
|
67
|
+
const { pid } = JSON.parse(readFileSync(ownerFile, "utf8"));
|
|
68
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) continue;
|
|
69
|
+
try { process.kill(pid, 0); } catch (error) { if (error.code === "ESRCH") rmSync(work, { recursive: true, force: true }); }
|
|
70
|
+
} catch { /* Unknown or still-active work directories are not ours to remove. */ }
|
|
71
|
+
}
|
|
72
|
+
temporary = mkdtempSync(path.join(outputRoot, "capture-work-"));
|
|
73
|
+
writeFileSync(path.join(temporary, "owner.json"), JSON.stringify({ pid: process.pid }));
|
|
74
|
+
const apps = [];
|
|
75
|
+
const { startAdsMcp } = await import("./mcp/server.mjs");
|
|
76
|
+
for (const [projectIndex, project] of projects.entries()) {
|
|
77
|
+
if (signal.aborted) throw new Error("Screenshot capture cancelled.");
|
|
78
|
+
if (!project.apps.length) { hosts.push(null); continue; }
|
|
79
|
+
const host = await startAdsMcp(project.options.root, { stdio: false, showcase: true });
|
|
80
|
+
hosts.push(host);
|
|
81
|
+
for (const selection of project.apps) {
|
|
82
|
+
const url = new URL(host.url); url.searchParams.set("scenario", selection.scenario); url.searchParams.set("capture", "1"); url.searchParams.set("theme", selection.theme);
|
|
83
|
+
apps.push({ ...selection, projectIndex, url: url.href });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const request = path.join(temporary, "request.json"), report = path.join(temporary, "report.json"), runId = randomUUID();
|
|
87
|
+
const settings = { browser: "chromium", timeoutMs: 60000, ...options.config.mcp.capture };
|
|
88
|
+
writeFileSync(request, JSON.stringify({ schemaVersion: 1, runId, playwright, settings, apps, output: temporary }));
|
|
89
|
+
update("rendering", 0, apps.length);
|
|
90
|
+
const run = await executeDevelopmentCommand({ command: "node", timeoutMs: settings.timeoutMs, label: "Screenshot capture" }, [fileURLToPath(new URL("./capture/worker.mjs", import.meta.url)), request], root, {}, signal);
|
|
91
|
+
if (run.code !== 0) throw new Error(`Screenshot browser failed (${run.code}): ${run.output.slice(-3000)}`);
|
|
92
|
+
if (!existsSync(report) || realpathSync(report) !== report || lstatSync(report).size > 128 * 1024) throw new Error("Screenshot runner produced no bounded report.");
|
|
93
|
+
const value = JSON.parse(readFileSync(report, "utf8"));
|
|
94
|
+
if (value.schemaVersion !== 1 || value.runId !== runId || !Array.isArray(value.images) || value.images.length !== apps.length) throw new Error("Screenshot runner returned the wrong run identity or image count.");
|
|
95
|
+
const images = new Map();
|
|
96
|
+
for (const [index, app] of apps.entries()) {
|
|
97
|
+
const file = path.join(temporary, `${index}.png`), stat = lstatSync(file);
|
|
98
|
+
if (!stat.isFile() || stat.nlink !== 1 || realpathSync(file) !== file || stat.size > captureLimits.imageBytes) throw new Error("Screenshot output must be an ordinary PNG no larger than 4 MiB.");
|
|
99
|
+
const bytes = readFileSync(file), metadata = await sharp(bytes).metadata();
|
|
100
|
+
if (metadata.format !== "png" || !metadata.width || !metadata.height || metadata.width * metadata.height > captureLimits.pixels) throw new Error("Screenshot output exceeds 8 megapixels or is not PNG.");
|
|
101
|
+
const project = projects[app.projectIndex];
|
|
102
|
+
images.set(`${app.projectIndex}:${app.selectionIndex}`, { mimeType: "image/png", blob: bytes.toString("base64"), digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, width: metadata.width, height: metadata.height, bytes: bytes.length, capturedAt: value.images[index].capturedAt, sourceRevision: project.revision, buildRevision: hosts[app.projectIndex].previewRevision(), settings: { theme: app.theme, viewport: app.viewport, animations: app.animations, browser: settings.browser, ...(settings.channel ? { channel: settings.channel } : {}), scale: "css" }, scenarioFile: app.scenarioFile, fixtureFile: app.fixtureFile });
|
|
103
|
+
}
|
|
104
|
+
for (const project of projects) if (previewSourceRevision(loadConfig(project.options.root)) !== project.revision) throw new Error("Project inputs changed while capturing. No capture slots were replaced; retry against current source.");
|
|
105
|
+
if (signal.aborted) throw new Error("Screenshot capture cancelled before publication.");
|
|
106
|
+
update("publishing", apps.length, apps.length);
|
|
107
|
+
const records = selections.map((selection, index) => ({ schemaVersion: 1, key: captureKey(selection), scenario: selection.scenario, ...(selection.target ? { target: selection.target } : {}), current: images.get(`0:${index}`) ?? null, ...(!images.has(`0:${index}`) ? { proposedTarget: true } : {}), candidate: candidateRoot ? { ...images.get(`1:${index}`), baseRevision: projects[0].revision, proposal } : null }));
|
|
108
|
+
if (!publish) return { records, baseRevision: projects[0].revision };
|
|
109
|
+
store.commit(records);
|
|
110
|
+
return { ok: true, captures: store.list(inspectScenarios(loadConfig(root)).scenarios, projects[0].revision).captures.filter(record => selections.some(selection => captureKey(selection) === record.key)) };
|
|
111
|
+
} finally {
|
|
112
|
+
try { await Promise.all(hosts.map(host => host?.close())); }
|
|
113
|
+
finally { clearInterval(heartbeat); try { if (temporary) rmSync(temporary, { recursive: true, force: true }); } finally { active = undefined; progress = undefined; finish(); } }
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
package/dist/config.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export const defaultConfig = {
|
|
|
17
17
|
boundaries: { styleWrites: [], componentVariables: [] },
|
|
18
18
|
assets: { publicDirs: ["public/assets"], generatedPaths: ["dist", "dist-showcase", ".vite", ".ds"], generatedFiles: [], iconSource: null },
|
|
19
19
|
oxlint: { plugins: [], rules: {}, overrides: [] },
|
|
20
|
-
mcp: { showcaseUrl: null, showcaseTitle: null, apps: [], previewWatch: null, previewManifest: null, mutationChecks: null, behaviorProfiles: {} },
|
|
20
|
+
mcp: { showcaseUrl: null, showcaseTitle: null, apps: [], previewWatch: null, previewManifest: null, mutationChecks: null, behaviorProfiles: {}, capture: {} },
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export function isInside(parent, file) {
|
|
@@ -100,6 +100,11 @@ export function loadConfig(directory = process.cwd()) {
|
|
|
100
100
|
}
|
|
101
101
|
if (config.mcp.showcaseTitle !== null && (typeof config.mcp.showcaseTitle !== "string" || !config.mcp.showcaseTitle.trim() || config.mcp.showcaseTitle.length > 120)) throw new Error("mcp.showcaseTitle must be a non-empty title of at most 120 characters or null.");
|
|
102
102
|
if (!Array.isArray(config.mcp.apps)) throw new Error("mcp.apps must be an array of built project previews.");
|
|
103
|
+
const capture = config.mcp.capture;
|
|
104
|
+
if (!capture || typeof capture !== "object" || Array.isArray(capture) || Object.keys(capture).some(key => !["browser", "channel", "timeoutMs"].includes(key))
|
|
105
|
+
|| !["chromium", "firefox", "webkit"].includes(capture.browser ?? "chromium")
|
|
106
|
+
|| capture.channel !== undefined && (typeof capture.channel !== "string" || !/^[\w-]{1,60}$/.test(capture.channel) || ![undefined, "chromium"].includes(capture.browser))
|
|
107
|
+
|| capture.timeoutMs !== undefined && (!Number.isInteger(capture.timeoutMs) || capture.timeoutMs < 100 || capture.timeoutMs > 300000)) throw new Error("mcp.capture accepts browser (chromium/firefox/webkit), an optional Chromium channel, and timeoutMs (100–300000). Install Playwright and its browser in the project; ADS installs nothing.");
|
|
103
108
|
if (!config.mcp.behaviorProfiles || typeof config.mcp.behaviorProfiles !== "object" || Array.isArray(config.mcp.behaviorProfiles) || Object.keys(config.mcp.behaviorProfiles).length > 20) throw new Error("mcp.behaviorProfiles must be an object of up to 20 registered execution profiles.");
|
|
104
109
|
for (const [name, profile] of Object.entries(config.mcp.behaviorProfiles)) {
|
|
105
110
|
if (!/^[a-zA-Z0-9][\w.-]{0,79}$/.test(name) || !profile || typeof profile !== "object" || Array.isArray(profile)
|
package/dist/mcp/context.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { projectGuideFiles } from "../project-documents.mjs";
|
|
|
5
5
|
|
|
6
6
|
export const GUIDE_URI = "ads://project/guide";
|
|
7
7
|
// Put the task triggers and entry path first: some hosts shorten server guidance.
|
|
8
|
-
export const serverInstructions = "Use ADS for UI work: components, patterns, controllers, hooks, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner; use ads_apply_transaction only for coupled owner patches. Find existing public UI with ads_catalog; discover its previews with ads_scenarios and open ads_show_scenario. Starting ADS also serves its built-in showcase: use ads_status.showcaseUrl for browser review instead of starting another runtime/compiler or project browser app. Read one relevant ADS skill before editing. Inspect the real preview, edit its owned source, check the build and review again. Scope source/guides to the project, its instruction ancestry and user-selected import/reference sources; broad searches of /mnt or home are not ADS discovery. Domain-model/requirements work is a separate project workflow. For behavior work, read ads://guides/behaviors and use ads_behaviors/ads_verify: scenario exercises are demonstrations, and registered guarantees need current passing execution evidence. Use the configured project framework and declared dependencies; MCP hosts supply no ad hoc APIs. MCP source checks do not replace browser tests; direct edits still need project checks. Reading HTML is not rendering, and reading a skill is not native activation. A running watcher is not proof of current source: inspect ads_status.build or ads://project/build after edits. Notifications may not reach the agent. Only explicitly enabled mutation tools edit project-owned ADS units after validation. Checks and compiler commands come from validated project configuration, never an ad hoc command argument.";
|
|
8
|
+
export const serverInstructions = "Use ADS for UI work: components, patterns, controllers, hooks, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner; use ads_apply_transaction only for coupled owner patches. Find existing public UI with ads_catalog; discover its previews with ads_scenarios and open ads_show_scenario. Starting ADS also serves its built-in showcase: use ads_status.showcaseUrl for browser review instead of starting another runtime/compiler or project browser app. Read one relevant ADS skill before editing. Inspect the real preview, edit its owned source, check the build and review again. Scope source/guides to the project, its instruction ancestry and user-selected import/reference sources; broad searches of /mnt or home are not ADS discovery. Domain-model/requirements work is a separate project workflow. For requested visual evidence, use ads_capture or screenshot-enabled patch dry runs; read ads://guides/captures for named targets, image resources and the current/candidate lifecycle. For behavior work, read ads://guides/behaviors and use ads_behaviors/ads_verify: scenario exercises are demonstrations, and registered guarantees need current passing execution evidence. Use the configured project framework and declared dependencies; MCP hosts supply no ad hoc APIs. MCP source checks do not replace browser tests; direct edits still need project checks. Reading HTML is not rendering, and reading a skill is not native activation. A running watcher is not proof of current source: inspect ads_status.build or ads://project/build after edits. Notifications may not reach the agent. Only explicitly enabled mutation tools edit project-owned ADS units after validation. Checks and compiler commands come from validated project configuration, never an ad hoc command argument.";
|
|
9
9
|
|
|
10
10
|
function manager(root, pkg) {
|
|
11
11
|
if (typeof pkg.packageManager === "string") return pkg.packageManager.split("@")[0];
|
package/dist/mcp/mutations.mjs
CHANGED
|
@@ -11,6 +11,9 @@ import { generateLintConfig } from "../lint-config.mjs";
|
|
|
11
11
|
import { generateBrandAssets } from "../brand-assets.mjs";
|
|
12
12
|
import { projectContext } from "./context.mjs";
|
|
13
13
|
import { applyFilePatch, parseUnifiedPatch } from "./patch.mjs";
|
|
14
|
+
import { createCaptureController, captureSelections } from "../capture.mjs";
|
|
15
|
+
import { previewSourceRevision } from "../preview-inputs.mjs";
|
|
16
|
+
import { inspectScenarios } from "../scenarios.mjs";
|
|
14
17
|
import { checkUnitCandidate, checkUnitFiles, inspectUnits, validateUnitId } from "../units.mjs";
|
|
15
18
|
|
|
16
19
|
export const mutationLimits = { units: 8, files: 32, patchBytes: 256 * 1024, patchFileBytes: 16 * 1024 * 1024, fileBytes: 16 * 1024 * 1024, readChars: 12000, snapshotFiles: 20000, snapshotBytes: 128 * 1024 * 1024 };
|
|
@@ -143,10 +146,11 @@ function checks(options) {
|
|
|
143
146
|
return scripts.map(script => ({ command: context.packageManager, args: ["run", script] }));
|
|
144
147
|
}
|
|
145
148
|
|
|
146
|
-
export function createMutationController(directory) {
|
|
149
|
+
export function createMutationController(directory, { captures = createCaptureController(directory) } = {}) {
|
|
147
150
|
const root = path.resolve(directory);
|
|
148
151
|
let busy = false;
|
|
149
152
|
let lastResult = null;
|
|
153
|
+
let progress;
|
|
150
154
|
let active;
|
|
151
155
|
return {
|
|
152
156
|
status() {
|
|
@@ -154,7 +158,7 @@ export function createMutationController(directory) {
|
|
|
154
158
|
let commands = [], diagnostics = [];
|
|
155
159
|
try { commands = checks(options).map(check => [check.command, ...check.args].join(" ")); }
|
|
156
160
|
catch (error) { diagnostics = [error.diagnostic ?? { rule: "mutation-check", message: error.message }]; }
|
|
157
|
-
return { enabled: true, scope: "one-ds-unit", format: "git-unified-diff", transactions:{tool:"ads_apply_transaction",maxUnits:mutationLimits.units,finalCandidateChecks:true}, preparation:{owner:"ads_prepare_patch",tokenSamples:"ads_prepare_token_samples"}, dsDir: options.config.dsDir, limits: mutationLimits, checks: ["ads check", "registered Oxlint rules", ...commands], diagnostics, busy, lastResult };
|
|
161
|
+
return { enabled: true, scope: "one-ds-unit", format: "git-unified-diff", transactions:{tool:"ads_apply_transaction",maxUnits:mutationLimits.units,finalCandidateChecks:true}, preparation:{owner:"ads_prepare_patch",tokenSamples:"ads_prepare_token_samples"}, dsDir: options.config.dsDir, limits: mutationLimits, checks: ["ads check", "registered Oxlint rules", ...commands], diagnostics, busy, ...(progress ? { progress } : {}), lastResult };
|
|
158
162
|
},
|
|
159
163
|
cancel() { active?.abort(); },
|
|
160
164
|
units() { return inspectUnits(loadConfig(root)).units; },
|
|
@@ -170,7 +174,7 @@ export function createMutationController(directory) {
|
|
|
170
174
|
return { ...source, unit, content, offset, nextOffset: end < source.content.length ? end : null, totalChars: source.content.length,
|
|
171
175
|
startLine: source.content.slice(0, offset).split("\n").length };
|
|
172
176
|
},
|
|
173
|
-
async apply({ unit, patch, patchFile, patches, dryRun = false }, signal) {
|
|
177
|
+
async apply({ unit, patch, patchFile, patches, dryRun = false, screenshot = false, captures: selections }, signal, notifyProgress) {
|
|
174
178
|
if (busy) return { ok: false, applied: false, diagnostics: [{ rule: "mutation-busy", message: "Another mutation is validating; retry after it finishes." }] };
|
|
175
179
|
busy = true;
|
|
176
180
|
active = new AbortController();
|
|
@@ -179,6 +183,8 @@ export function createMutationController(directory) {
|
|
|
179
183
|
const verified = [];
|
|
180
184
|
try {
|
|
181
185
|
const options = loadConfig(root);
|
|
186
|
+
if (screenshot && !dryRun || selections !== undefined && !screenshot) throw fail("mutation-capture", "Use screenshot:true only with dryRun:true and an explicit captures:[{scenario,target?,theme?,viewport?}] selection. Ordinary patches create no screenshots.");
|
|
187
|
+
if (screenshot) selections = captureSelections(selections);
|
|
182
188
|
if (patches !== undefined && (unit !== undefined || patch !== undefined || patchFile !== undefined || !Array.isArray(patches) || patches.length < 2 || patches.length > mutationLimits.units)) throw fail("mutation-batch", "A transaction requires 2–8 distinct owner patches, without a top-level unit/patch.");
|
|
183
189
|
const groups = patches ?? [{unit,patch,patchFile}];
|
|
184
190
|
if (new Set(groups.map(group => group.unit)).size !== groups.length) throw fail("mutation-batch", "Use one patch per distinct unit in a transaction.");
|
|
@@ -279,17 +285,29 @@ export function createMutationController(directory) {
|
|
|
279
285
|
for (const check of extraChecks) verified.push(await checkCommand(check.command, check.args, candidate, signal));
|
|
280
286
|
const behaviorUnits = affectedBehaviorUnits(candidateOptions, changes.map(change => change.path));
|
|
281
287
|
if (behaviorUnits.length) {
|
|
282
|
-
const verification = createVerificationController(candidate);
|
|
288
|
+
const verification = createVerificationController(candidate, { reportRoot: root, onProgress: value => { progress = { stage: "behavior", ...value }; notifyProgress?.(progress); } });
|
|
283
289
|
let evidence;
|
|
284
290
|
try { evidence = await verification.run({ units: behaviorUnits, persist: false }, signal); }
|
|
285
291
|
finally { await verification.close(); }
|
|
286
292
|
if (!evidence.ok) return lastResult = { ok: false, applied: false, diagnostics: [...evidence.diagnostics, ...evidence.results.filter(item => item.status !== "passed").map(item => ({ rule: "behavior-check", message: `${item.unit}#${item.check}: ${item.status}. ${item.message}` }))].slice(0, 50), checks: verified, behavior: evidence };
|
|
287
293
|
verified.push({ name: "registered behavior checks", passed: true, results: evidence.results });
|
|
288
294
|
}
|
|
295
|
+
let screenshots;
|
|
296
|
+
if (screenshot) {
|
|
297
|
+
progress = { stage: "capture", total: selections.length, completed: 0, active: [], profile: "screenshots", elapsedMs: 0 };
|
|
298
|
+
notifyProgress?.(progress);
|
|
299
|
+
const proposal = digest(Buffer.from(JSON.stringify(changes.map(change => [change.path, change.content === null ? null : digest(Buffer.from(change.content))]))));
|
|
300
|
+
screenshots = await captures.run({ captures: selections, candidateRoot: candidate, proposal, publish: false }, signal, value => { progress = value; notifyProgress?.(value); });
|
|
301
|
+
}
|
|
289
302
|
if (signal?.aborted) throw fail("mutation-cancelled", "Mutation cancelled before application.");
|
|
290
303
|
if (snapshot(candidateOptions) !== expected) throw fail("mutation-check", "A validation command changed source files. Use checks without source-writing side effects.");
|
|
291
304
|
if (snapshot(loadConfig(root)) !== before) throw fail("mutation-conflict", "Project inputs changed during validation; no changes were applied. Read current source and retry.");
|
|
292
305
|
for (const change of changes) address(options, change.path);
|
|
306
|
+
if (screenshots) {
|
|
307
|
+
captures.store.commit(screenshots.records);
|
|
308
|
+
const selected = new Set(screenshots.records.map(record => record.key));
|
|
309
|
+
screenshots = { ok: true, captures: captures.store.list(inspectScenarios(loadConfig(root)).scenarios, screenshots.baseRevision).captures.filter(record => selected.has(record.key)) };
|
|
310
|
+
}
|
|
293
311
|
if (!dryRun) {
|
|
294
312
|
const written = [];
|
|
295
313
|
const directories = [];
|
|
@@ -317,11 +335,13 @@ export function createMutationController(directory) {
|
|
|
317
335
|
}
|
|
318
336
|
const warnings = [];
|
|
319
337
|
if (!dryRun && (assets || configuration)) { try { await generateBrandAssets(root); } catch (error) { warnings.push({ rule: "mutation-assets", message: `Source was applied; generated brand output needs recovery: ${error.message}` }); } }
|
|
338
|
+
if (!dryRun && captures.store.fingerprint()) { try { captures.store.promote(previewSourceRevision(loadConfig(root)), inspectScenarios(loadConfig(root)).scenarios); } catch (error) { warnings.push({ rule: "mutation-capture", message: `Source was applied; capture promotion needs recovery: ${error.message}` }); } }
|
|
320
339
|
return lastResult = { ok: true, ...(patches ? {units,transaction:true} : {unit}), applied: !dryRun, changes: changes.map(change => ({ path: change.path, operation: change.content === null ? "delete" : change.expectedDigest === null ? "create" : "update", digest: change.content === null ? null : digest(Buffer.from(change.content)) })), checks: verified, ...(result.advisories?.length ? { advisories: result.advisories.slice(0, 50), advisoryCount: result.advisories.length } : {}), ...(warnings.length ? { warnings } : {}),
|
|
340
|
+
...(screenshots ? { screenshots } : {}),
|
|
321
341
|
preview: "Source validation passed. Inspect the managed preview build and rendered scenario after applying; validation is not visual verification." };
|
|
322
342
|
} catch (error) {
|
|
323
343
|
return lastResult = { ok: false, applied: false, diagnostics: [error.diagnostic ?? { rule: "mutation-error", message: error.message }], checks: verified };
|
|
324
|
-
} finally { try { if (temporary) rmSync(temporary, { recursive: true, force: true }); } finally { busy = false; active = undefined; } }
|
|
344
|
+
} finally { try { if (temporary) rmSync(temporary, { recursive: true, force: true }); } finally { busy = false; active = undefined; progress = undefined; } }
|
|
325
345
|
},
|
|
326
346
|
};
|
|
327
347
|
}
|