@50arms/platform-mcp 0.0.0-stage → 0.1.436
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 +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +93 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- package/package.json +51 -4
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// src/machine-toolkit/cli.ts
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
// src/machine-toolkit/contract.ts
|
|
6
|
+
var CONTRACT_VERSION = 2;
|
|
7
|
+
var EXIT = {
|
|
8
|
+
/** The command ran and everything it reports was read or done. */
|
|
9
|
+
ok: 0,
|
|
10
|
+
/** The command could not run, or part of its work failed. */
|
|
11
|
+
failed: 1,
|
|
12
|
+
/** `health` / `label-check` ran and answered, but at least one metric could not be read (it is `null` with a reason), or a label problem exists. */
|
|
13
|
+
degraded: 2,
|
|
14
|
+
/** `sweep` / `maintain` refused to start because a compaction window is open (the only refusal today). */
|
|
15
|
+
refused: 3
|
|
16
|
+
};
|
|
17
|
+
var REQUIRED_METRICS = ["docker_data_disk_bytes", "unowned_docker_resource_count", "orphan_process_count"];
|
|
18
|
+
var HEALTH_ITEM_LISTS = ["unowned_docker_resources", "orphan_processes"];
|
|
19
|
+
var ContractViolation = class extends Error {
|
|
20
|
+
constructor(command, path, what) {
|
|
21
|
+
super(`${command} output does not match machine-toolkit contract v${CONTRACT_VERSION}: ${path} ${what}`);
|
|
22
|
+
this.name = "ContractViolation";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
26
|
+
var join = (path, key) => path === "" ? key : `${path}.${key}`;
|
|
27
|
+
function objectAt(v, path, fail2) {
|
|
28
|
+
if (!isObject(v)) return fail2(path, "must be an object");
|
|
29
|
+
return v;
|
|
30
|
+
}
|
|
31
|
+
function exactKeys(o, path, keys, fail2) {
|
|
32
|
+
for (const k of keys) if (!(k in o)) fail2(join(path, k), "is required");
|
|
33
|
+
for (const k of Object.keys(o)) if (!keys.includes(k)) fail2(join(path, k), "is not part of the contract");
|
|
34
|
+
}
|
|
35
|
+
function stringAt(o, key, path, fail2) {
|
|
36
|
+
const v = o[key];
|
|
37
|
+
if (typeof v !== "string" || v === "") return fail2(join(path, key), "must be a non-empty string");
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
function listAt(v, path, fail2) {
|
|
41
|
+
if (!Array.isArray(v)) return fail2(path, "must be a list");
|
|
42
|
+
return v;
|
|
43
|
+
}
|
|
44
|
+
function checkItem(v, path, extra, fail2) {
|
|
45
|
+
const o = objectAt(v, path, fail2);
|
|
46
|
+
exactKeys(o, path, ["id", "detail", ...extra], fail2);
|
|
47
|
+
stringAt(o, "id", path, fail2);
|
|
48
|
+
if (typeof o.detail !== "string") fail2(join(path, "detail"), "must be a string");
|
|
49
|
+
for (const k of extra) stringAt(o, k, path, fail2);
|
|
50
|
+
}
|
|
51
|
+
function checkItems(v, path, extra, fail2) {
|
|
52
|
+
listAt(v, path, fail2).forEach((item, i) => checkItem(item, `${path}[${i}]`, extra, fail2));
|
|
53
|
+
}
|
|
54
|
+
function checkMetric(v, path, fail2) {
|
|
55
|
+
const o = objectAt(v, path, fail2);
|
|
56
|
+
exactKeys(o, path, ["value", "reason", "source", "age_seconds"], fail2);
|
|
57
|
+
if (o.value === null) {
|
|
58
|
+
if (typeof o.reason !== "string" || o.reason === "") fail2(`${path}.reason`, "must say why the value is null");
|
|
59
|
+
} else {
|
|
60
|
+
if (typeof o.value !== "number" || !Number.isFinite(o.value) || o.value < 0) fail2(`${path}.value`, "must be null or a finite number of at least 0");
|
|
61
|
+
if (o.reason !== null) fail2(`${path}.reason`, "must be null when the value was read");
|
|
62
|
+
}
|
|
63
|
+
if (o.source !== "fresh" && o.source !== "cached") fail2(`${path}.source`, "must be 'fresh' or 'cached'");
|
|
64
|
+
if (typeof o.age_seconds !== "number" || !Number.isFinite(o.age_seconds) || o.age_seconds < 0) fail2(`${path}.age_seconds`, "must be a number of at least 0");
|
|
65
|
+
if (o.source === "fresh" && o.age_seconds !== 0) fail2(`${path}.age_seconds`, "must be 0 for a fresh reading");
|
|
66
|
+
}
|
|
67
|
+
function checkEnvelope(o, fail2) {
|
|
68
|
+
if (o.contract_version !== CONTRACT_VERSION) fail2("contract_version", `must be ${CONTRACT_VERSION}`);
|
|
69
|
+
stringAt(o, "toolkit_version", "", fail2);
|
|
70
|
+
stringAt(o, "profile", "", fail2);
|
|
71
|
+
const at = stringAt(o, "measured_at", "", fail2);
|
|
72
|
+
if (Number.isNaN(new Date(at).getTime())) fail2("measured_at", "must be an ISO time");
|
|
73
|
+
}
|
|
74
|
+
var ENVELOPE_KEYS = ["contract_version", "toolkit_version", "profile", "measured_at"];
|
|
75
|
+
var VALIDATORS = {
|
|
76
|
+
health(o, fail2) {
|
|
77
|
+
exactKeys(o, "", [...ENVELOPE_KEYS, "metrics", "items"], fail2);
|
|
78
|
+
checkEnvelope(o, fail2);
|
|
79
|
+
const metrics = objectAt(o.metrics, "metrics", fail2);
|
|
80
|
+
for (const name of REQUIRED_METRICS) if (!(name in metrics)) fail2(`metrics.${name}`, "is required");
|
|
81
|
+
for (const [name, metric] of Object.entries(metrics)) checkMetric(metric, `metrics.${name}`, fail2);
|
|
82
|
+
const items = objectAt(o.items, "items", fail2);
|
|
83
|
+
exactKeys(items, "items", HEALTH_ITEM_LISTS, fail2);
|
|
84
|
+
for (const list of HEALTH_ITEM_LISTS) checkItems(items[list], `items.${list}`, [], fail2);
|
|
85
|
+
},
|
|
86
|
+
sweep(o, fail2) {
|
|
87
|
+
exactKeys(o, "", [...ENVELOPE_KEYS, "dry_run", "kinds"], fail2);
|
|
88
|
+
checkEnvelope(o, fail2);
|
|
89
|
+
if (typeof o.dry_run !== "boolean") fail2("dry_run", "must be true or false");
|
|
90
|
+
listAt(o.kinds, "kinds", fail2).forEach((k, i) => {
|
|
91
|
+
const path = `kinds[${i}]`;
|
|
92
|
+
const kind = objectAt(k, path, fail2);
|
|
93
|
+
exactKeys(kind, path, ["kind", "removed", "kept", "failed", "flagged"], fail2);
|
|
94
|
+
stringAt(kind, "kind", path, fail2);
|
|
95
|
+
checkItems(kind.removed, `${path}.removed`, [], fail2);
|
|
96
|
+
checkItems(kind.kept, `${path}.kept`, ["reason"], fail2);
|
|
97
|
+
checkItems(kind.failed, `${path}.failed`, ["error"], fail2);
|
|
98
|
+
checkItems(kind.flagged, `${path}.flagged`, ["reason"], fail2);
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
maintain(o, fail2) {
|
|
102
|
+
exactKeys(o, "", [...ENVELOPE_KEYS, "steps"], fail2);
|
|
103
|
+
checkEnvelope(o, fail2);
|
|
104
|
+
listAt(o.steps, "steps", fail2).forEach((s, i) => {
|
|
105
|
+
const path = `steps[${i}]`;
|
|
106
|
+
const step = objectAt(s, path, fail2);
|
|
107
|
+
exactKeys(step, path, ["name", "status", "detail"], fail2);
|
|
108
|
+
stringAt(step, "name", path, fail2);
|
|
109
|
+
if (step.status !== "done" && step.status !== "skipped" && step.status !== "failed") fail2(`${path}.status`, "must be 'done', 'skipped' or 'failed'");
|
|
110
|
+
if (typeof step.detail !== "string") fail2(join(path, "detail"), "must be a string");
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
"label-check"(o, fail2) {
|
|
114
|
+
exactKeys(o, "", [...ENVELOPE_KEYS, "unowned", "malformed"], fail2);
|
|
115
|
+
checkEnvelope(o, fail2);
|
|
116
|
+
checkItems(o.unowned, "unowned", ["reason"], fail2);
|
|
117
|
+
checkItems(o.malformed, "malformed", ["reason"], fail2);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
function assertContract(command, report) {
|
|
121
|
+
const fail2 = (path, what) => {
|
|
122
|
+
throw new ContractViolation(command, path === "" ? "(document)" : path, what);
|
|
123
|
+
};
|
|
124
|
+
VALIDATORS[command](objectAt(report, "", fail2), fail2);
|
|
125
|
+
}
|
|
126
|
+
var metricRead = (value, source = "fresh", ageSeconds = 0) => ({ value, reason: null, source, age_seconds: ageSeconds });
|
|
127
|
+
var metricUnread = (reason) => ({ value: null, reason, source: "fresh", age_seconds: 0 });
|
|
128
|
+
function healthExitCode(report) {
|
|
129
|
+
return Object.values(report.metrics).some((m) => m.value === null) ? EXIT.degraded : EXIT.ok;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/machine-toolkit/docker-data-disk.ts
|
|
133
|
+
import { statSync } from "node:fs";
|
|
134
|
+
import { join as join2 } from "node:path";
|
|
135
|
+
var DATA_DISK_RELATIVE = ["Docker", "wsl", "disk", "docker_data.vhdx"];
|
|
136
|
+
function readDockerDataDisk(localAppData = process.env.LOCALAPPDATA, stat = statSync) {
|
|
137
|
+
if (!localAppData) return { path: null, bytes: null, unavailableReason: "LOCALAPPDATA is not set (not a Windows session)" };
|
|
138
|
+
const path = join2(localAppData, ...DATA_DISK_RELATIVE);
|
|
139
|
+
try {
|
|
140
|
+
return { path, bytes: stat(path).size };
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err.code === "ENOENT") return { path, bytes: null, unavailableReason: "the file does not exist (Docker Desktop's WSL2 backend is not in use here)" };
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/machine-toolkit/docker-flags.mjs
|
|
148
|
+
import { readFileSync } from "node:fs";
|
|
149
|
+
import { join as join4 } from "node:path";
|
|
150
|
+
|
|
151
|
+
// src/machine-toolkit/machine-home.mjs
|
|
152
|
+
import { execFileSync } from "node:child_process";
|
|
153
|
+
import { existsSync } from "node:fs";
|
|
154
|
+
import { homedir, release } from "node:os";
|
|
155
|
+
import { join as join3 } from "node:path";
|
|
156
|
+
function isWsl(kernelRelease = release(), platform = process.platform, hasDrvFs = existsSync("/mnt/c/Users")) {
|
|
157
|
+
return platform === "linux" && /microsoft/i.test(kernelRelease) && hasDrvFs;
|
|
158
|
+
}
|
|
159
|
+
var WSL_CALL_TIMEOUT_MS = 1e4;
|
|
160
|
+
function windowsHomeFromWsl() {
|
|
161
|
+
const raw = execFileSync("cmd.exe", ["/c", "echo %USERPROFILE%"], { windowsHide: true, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: WSL_CALL_TIMEOUT_MS }).replace(/\r/g, "").trim();
|
|
162
|
+
if (raw === "" || raw.includes("%")) throw new Error(`could not resolve the Windows home from cmd.exe (it said '${raw}')`);
|
|
163
|
+
const path = execFileSync("wslpath", ["-u", raw], { windowsHide: true, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: WSL_CALL_TIMEOUT_MS }).trim();
|
|
164
|
+
if (!existsSync(path)) throw new Error(`the Windows home '${raw}' is not a directory under /mnt/c`);
|
|
165
|
+
return path;
|
|
166
|
+
}
|
|
167
|
+
var COMPACTION_WINDOW_FILE = "compaction-window";
|
|
168
|
+
function compactionWindowOpen(home = machineHome()) {
|
|
169
|
+
return existsSync(join3(home, ".danxbot", COMPACTION_WINDOW_FILE));
|
|
170
|
+
}
|
|
171
|
+
function machineHome() {
|
|
172
|
+
return isWsl() ? windowsHomeFromWsl() : homedir();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/machine-toolkit/docker-flags.mjs
|
|
176
|
+
var FLAGS_STALE_MS = 3 * 60 * 60 * 1e3;
|
|
177
|
+
var UNOWNED_REASON_PREFIX = "UNOWNED: ";
|
|
178
|
+
function flagsFile(home = machineHome()) {
|
|
179
|
+
return join4(home, ".danxbot", "docker-flags.json");
|
|
180
|
+
}
|
|
181
|
+
function fail(path, what) {
|
|
182
|
+
throw Object.assign(new Error(`${path} is malformed: ${what}`), { code: "EMALFORMED" });
|
|
183
|
+
}
|
|
184
|
+
function validateFlags(value, path) {
|
|
185
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) fail(path, "not a JSON object");
|
|
186
|
+
if (typeof value.at !== "string" || Number.isNaN(new Date(value.at).getTime())) fail(path, `"at" must be an ISO time, not ${JSON.stringify(value.at)}`);
|
|
187
|
+
if (!Number.isInteger(value.unowned) || value.unowned < 0) fail(path, `"unowned" must be a whole number, not ${JSON.stringify(value.unowned)}`);
|
|
188
|
+
if (!Array.isArray(value.flagged)) fail(path, `"flagged" must be a list`);
|
|
189
|
+
for (const f of value.flagged) {
|
|
190
|
+
if (f === null || typeof f !== "object" || typeof f.id !== "string" || typeof f.reason !== "string" || !(f.card === null || typeof f.card === "string")) {
|
|
191
|
+
fail(path, `every "flagged" entry needs a string id and reason and a card (string or null), not ${JSON.stringify(f)}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { at: value.at, unowned: value.unowned, flagged: value.flagged.map((f) => ({ id: f.id, reason: f.reason, card: f.card })) };
|
|
195
|
+
}
|
|
196
|
+
function readFlags(path = flagsFile()) {
|
|
197
|
+
let text;
|
|
198
|
+
try {
|
|
199
|
+
text = readFileSync(path, "utf8");
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err.code === "ENOENT") return null;
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
let value;
|
|
205
|
+
try {
|
|
206
|
+
value = JSON.parse(text);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
fail(path, `not JSON (${err.message})`);
|
|
209
|
+
}
|
|
210
|
+
return validateFlags(value, path);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/machine-toolkit/deadline.ts
|
|
214
|
+
var realScheduler = (fire, ms) => {
|
|
215
|
+
const timer = setTimeout(fire, ms);
|
|
216
|
+
return () => clearTimeout(timer);
|
|
217
|
+
};
|
|
218
|
+
var DeadlineExceeded = class extends Error {
|
|
219
|
+
};
|
|
220
|
+
function withDeadline(work, ms, schedule, what) {
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const cancel = schedule(() => reject(new DeadlineExceeded(`${what} did not answer within ${ms / 1e3}s`)), ms);
|
|
223
|
+
work.then(
|
|
224
|
+
(v) => {
|
|
225
|
+
cancel();
|
|
226
|
+
resolve(v);
|
|
227
|
+
},
|
|
228
|
+
(e) => {
|
|
229
|
+
cancel();
|
|
230
|
+
reject(e);
|
|
231
|
+
}
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/machine-toolkit/errors.ts
|
|
237
|
+
function errorCode(err) {
|
|
238
|
+
const failure = err;
|
|
239
|
+
if (typeof failure?.code === "string" || typeof failure?.code === "number") return String(failure.code);
|
|
240
|
+
return failure?.killed === true ? "timed out" : "unexpected error";
|
|
241
|
+
}
|
|
242
|
+
function errMessage(err) {
|
|
243
|
+
return err instanceof Error ? err.message : String(err);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/machine-toolkit/toolkit.ts
|
|
247
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
248
|
+
var PROFILE = "windows-wsl-docker";
|
|
249
|
+
function resolveToolkitVersion(stamped, readManifest) {
|
|
250
|
+
return stamped ?? readManifest();
|
|
251
|
+
}
|
|
252
|
+
var readOwnManifest = () => JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
253
|
+
var TOOLKIT_VERSION = resolveToolkitVersion(false ? void 0 : "0.1.436", readOwnManifest);
|
|
254
|
+
function envelope(now) {
|
|
255
|
+
return { contract_version: CONTRACT_VERSION, toolkit_version: TOOLKIT_VERSION, profile: PROFILE, measured_at: now.toISOString() };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/machine-toolkit/health.ts
|
|
259
|
+
var HEALTH_BUDGET_MS = 1e4;
|
|
260
|
+
var HEALTH_MARGIN_MS = 1500;
|
|
261
|
+
var MIN_ORPHAN_DEADLINE_MS = 500;
|
|
262
|
+
function orphanDeadlineMs(uptimeSeconds) {
|
|
263
|
+
return Math.max(MIN_ORPHAN_DEADLINE_MS, Math.floor(HEALTH_BUDGET_MS - uptimeSeconds * 1e3 - HEALTH_MARGIN_MS));
|
|
264
|
+
}
|
|
265
|
+
var unread = (reason) => ({ metric: metricUnread(reason), items: [] });
|
|
266
|
+
async function read(reader, what) {
|
|
267
|
+
try {
|
|
268
|
+
return await reader();
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return unread(err instanceof DeadlineExceeded ? err.message : `${what} failed (${errorCode(err)})`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function collectHealth(src, opts = {}) {
|
|
274
|
+
const now = src.now();
|
|
275
|
+
const deadlineMs = opts.deadlineMs ?? orphanDeadlineMs(process.uptime());
|
|
276
|
+
const [dataDisk, unowned, orphans] = await Promise.all([
|
|
277
|
+
read(() => {
|
|
278
|
+
const disk = src.dataDisk();
|
|
279
|
+
return disk.bytes === null ? unread(disk.unavailableReason) : { metric: metricRead(disk.bytes), items: [] };
|
|
280
|
+
}, "reading docker_data.vhdx"),
|
|
281
|
+
read(() => {
|
|
282
|
+
const flags = src.flags();
|
|
283
|
+
if (flags === null) return unread("no real Docker sweep has written docker-flags.json on this machine yet");
|
|
284
|
+
const ageMs = Math.max(0, now.getTime() - new Date(flags.at).getTime());
|
|
285
|
+
if (ageMs > FLAGS_STALE_MS) return unread(`the last Docker sweep result is ${(ageMs / 36e5).toFixed(1)} h old (older than ${FLAGS_STALE_MS / 36e5} h): the hourly sweep has stopped, so its count is stale`);
|
|
286
|
+
const ageSeconds = Math.round(ageMs / 1e3);
|
|
287
|
+
const items = flags.flagged.filter((f) => f.reason.startsWith(UNOWNED_REASON_PREFIX)).map((f) => ({ id: f.id, detail: f.reason.slice(UNOWNED_REASON_PREFIX.length) }));
|
|
288
|
+
return { metric: metricRead(flags.unowned, "cached", ageSeconds), items };
|
|
289
|
+
}, "reading the Docker flags file"),
|
|
290
|
+
read(async () => {
|
|
291
|
+
if (src.compactionWindowOpen()) return unread("a compaction window is open, so processes are not listed until it closes");
|
|
292
|
+
const abort = new AbortController();
|
|
293
|
+
let report2;
|
|
294
|
+
try {
|
|
295
|
+
report2 = await withDeadline(src.orphans(now, abort.signal), deadlineMs, opts.schedule ?? realScheduler, "the process listing");
|
|
296
|
+
} catch (err) {
|
|
297
|
+
abort.abort();
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
if (report2.unavailable.length > 0) return unread(report2.unavailable.map((u) => `${u.source} processes not listed (${u.code})`).join("; "));
|
|
301
|
+
const items = report2.findings.map((f) => ({ id: `${f.source}:${f.pid}`, detail: `${f.kind} started ${f.startedAt.toISOString()}: ${f.commandLine}` }));
|
|
302
|
+
return { metric: metricRead(items.length), items };
|
|
303
|
+
}, "listing processes")
|
|
304
|
+
]);
|
|
305
|
+
const report = {
|
|
306
|
+
...envelope(now),
|
|
307
|
+
metrics: {
|
|
308
|
+
docker_data_disk_bytes: dataDisk.metric,
|
|
309
|
+
unowned_docker_resource_count: unowned.metric,
|
|
310
|
+
orphan_process_count: orphans.metric
|
|
311
|
+
},
|
|
312
|
+
items: { unowned_docker_resources: unowned.items, orphan_processes: orphans.items }
|
|
313
|
+
};
|
|
314
|
+
assertContract("health", report);
|
|
315
|
+
return report;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/machine-toolkit/processes.ts
|
|
319
|
+
import { execFile } from "node:child_process";
|
|
320
|
+
var ORPHAN_MIN_AGE_MS = 10 * 60 * 1e3;
|
|
321
|
+
var COMMAND_LINE_MAX_CHARS = 140;
|
|
322
|
+
var PROCESS_LISTING_TIMEOUT_MS = 2e4;
|
|
323
|
+
var WSL_SHELL_NAMES = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "dash"]);
|
|
324
|
+
var DOCKER_BUILD = /(?:^|[\\/"\s])docker(?:\.exe)?"?\s+(?:buildx\s+)?build\b/;
|
|
325
|
+
var NODE_NAMES = /* @__PURE__ */ new Set(["node", "node.exe"]);
|
|
326
|
+
var INIT_PID = 1;
|
|
327
|
+
var WSL_DISTRO = "Ubuntu";
|
|
328
|
+
var DOCKER_DESKTOP_DISTROS = /* @__PURE__ */ new Set(["docker-desktop", "docker-desktop-data"]);
|
|
329
|
+
var LISTING_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
330
|
+
function kindOf(row) {
|
|
331
|
+
if (DOCKER_BUILD.test(row.commandLine)) return "docker build";
|
|
332
|
+
if (NODE_NAMES.has(row.name.toLowerCase())) return "node";
|
|
333
|
+
if (row.source === "wsl" && WSL_SHELL_NAMES.has(row.name.toLowerCase())) return "shell";
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
function parentGone(row, bySourceAndPid) {
|
|
337
|
+
if (row.source === "wsl" && row.ppid === INIT_PID) return true;
|
|
338
|
+
const parent = bySourceAndPid.get(`${row.source}:${row.ppid}`);
|
|
339
|
+
if (!parent) return true;
|
|
340
|
+
return parent.startedAt !== null && row.startedAt !== null && parent.startedAt.getTime() > row.startedAt.getTime();
|
|
341
|
+
}
|
|
342
|
+
function findOrphans(rows, now) {
|
|
343
|
+
const bySourceAndPid = new Map(rows.map((r) => [`${r.source}:${r.pid}`, r]));
|
|
344
|
+
const found = [];
|
|
345
|
+
for (const row of rows) {
|
|
346
|
+
const kind = kindOf(row);
|
|
347
|
+
if (kind === null || row.startedAt === null) continue;
|
|
348
|
+
if (now.getTime() - row.startedAt.getTime() < ORPHAN_MIN_AGE_MS) continue;
|
|
349
|
+
if (!parentGone(row, bySourceAndPid)) continue;
|
|
350
|
+
found.push({ source: row.source, pid: row.pid, kind, startedAt: row.startedAt, commandLine: row.commandLine.slice(0, COMMAND_LINE_MAX_CHARS) });
|
|
351
|
+
}
|
|
352
|
+
return found.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime());
|
|
353
|
+
}
|
|
354
|
+
var WINDOWS_LISTING_SCRIPT = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ pid = $_.ProcessId; ppid = $_.ParentProcessId; started = $(if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('o') } else { $null }); name = $_.Name; cmd = $_.CommandLine; exe = $_.ExecutablePath } } | ConvertTo-Json -Compress";
|
|
355
|
+
function parseWindowsListing(stdout) {
|
|
356
|
+
const parsed = JSON.parse(stdout);
|
|
357
|
+
return (Array.isArray(parsed) ? parsed : [parsed]).map((p) => ({
|
|
358
|
+
source: "windows",
|
|
359
|
+
pid: p.pid,
|
|
360
|
+
ppid: p.ppid,
|
|
361
|
+
startedAt: p.started ? new Date(p.started) : null,
|
|
362
|
+
name: p.name,
|
|
363
|
+
commandLine: p.cmd ?? "",
|
|
364
|
+
executablePath: p.exe ?? ""
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
function parseWslListing(stdout, now) {
|
|
368
|
+
const rows = [];
|
|
369
|
+
for (const line of stdout.split("\n")) {
|
|
370
|
+
if (line.trim() === "") continue;
|
|
371
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/.exec(line);
|
|
372
|
+
if (!m) throw new Error(`wsl ps printed a line this report cannot read: ${JSON.stringify(line)}`);
|
|
373
|
+
rows.push({ source: "wsl", pid: Number(m[1]), ppid: Number(m[2]), startedAt: new Date(now.getTime() - Number(m[3]) * 1e3), name: m[4], commandLine: m[5], executablePath: "" });
|
|
374
|
+
}
|
|
375
|
+
return rows;
|
|
376
|
+
}
|
|
377
|
+
var runCommand = (file, args, timeoutMs, signal) => new Promise((resolve, reject) => {
|
|
378
|
+
execFile(file, args, { timeout: timeoutMs, maxBuffer: LISTING_MAX_BUFFER_BYTES, windowsHide: true, signal }, (err, stdout) => err ? reject(err) : resolve(stdout));
|
|
379
|
+
});
|
|
380
|
+
async function wslDistroRunning(run, signal) {
|
|
381
|
+
const stdout = await run("wsl.exe", ["--list", "--running", "--quiet"], PROCESS_LISTING_TIMEOUT_MS, signal);
|
|
382
|
+
const running = stdout.replace(/\0/g, "").split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "" && !DOCKER_DESKTOP_DISTROS.has(line));
|
|
383
|
+
if (running.length === 0) return false;
|
|
384
|
+
if (running.includes(WSL_DISTRO)) return true;
|
|
385
|
+
throw Object.assign(new Error(`${WSL_DISTRO} is not among the running WSL distros: ${running.join(", ")}`), { code: "distro not running" });
|
|
386
|
+
}
|
|
387
|
+
async function listProcesses(now = /* @__PURE__ */ new Date(), run = runCommand, platform = process.platform, signal, wsl = "always") {
|
|
388
|
+
if (platform !== "win32") return { rows: [], unavailable: [{ source: "windows", reason: "orphan-process listing is implemented for the Windows dev machine only", code: "unsupported platform" }] };
|
|
389
|
+
const listings = [
|
|
390
|
+
{ source: "windows", read: async () => parseWindowsListing(await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_LISTING_SCRIPT], PROCESS_LISTING_TIMEOUT_MS, signal)) },
|
|
391
|
+
{
|
|
392
|
+
source: "wsl",
|
|
393
|
+
read: async () => {
|
|
394
|
+
if (wsl === "when-running" && !await wslDistroRunning(run, signal)) return [];
|
|
395
|
+
return parseWslListing(await run("wsl.exe", ["-d", WSL_DISTRO, "--", "ps", "-eo", "pid=,ppid=,etimes=,comm=,args="], PROCESS_LISTING_TIMEOUT_MS, signal), now);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
];
|
|
399
|
+
const settled = await Promise.allSettled(listings.map((l) => l.read()));
|
|
400
|
+
const rows = [];
|
|
401
|
+
const unavailable = [];
|
|
402
|
+
settled.forEach((s, i) => {
|
|
403
|
+
if (s.status === "fulfilled") rows.push(...s.value);
|
|
404
|
+
else unavailable.push({ source: listings[i].source, reason: errMessage(s.reason), code: errorCode(s.reason) });
|
|
405
|
+
});
|
|
406
|
+
return { rows, unavailable };
|
|
407
|
+
}
|
|
408
|
+
async function reportOrphanProcesses(now = /* @__PURE__ */ new Date(), run = runCommand, platform = process.platform, signal, wsl = "always") {
|
|
409
|
+
const { rows, unavailable } = await listProcesses(now, run, platform, signal, wsl);
|
|
410
|
+
return { findings: findOrphans(rows, now), unavailable };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/machine-toolkit/cli.ts
|
|
414
|
+
async function runToolkitCli(args, io) {
|
|
415
|
+
const [command, ...rest] = args;
|
|
416
|
+
const extra = rest.filter((a) => a !== "--json");
|
|
417
|
+
if (command !== "health" || extra.length > 0) {
|
|
418
|
+
io.err(`machine-toolkit takes only: health --json (got '${args.join(" ")}')`);
|
|
419
|
+
return EXIT.failed;
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const report = await io.collect();
|
|
423
|
+
io.out(JSON.stringify(report, null, 2));
|
|
424
|
+
return healthExitCode(report);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
io.err(errMessage(err));
|
|
427
|
+
return EXIT.failed;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function collectFromThisMachine() {
|
|
431
|
+
return collectHealth({
|
|
432
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
433
|
+
dataDisk: () => readDockerDataDisk(),
|
|
434
|
+
flags: () => readFlags(),
|
|
435
|
+
orphans: (now, signal) => reportOrphanProcesses(now, void 0, void 0, signal, "when-running"),
|
|
436
|
+
compactionWindowOpen: () => compactionWindowOpen()
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function isMainModule() {
|
|
440
|
+
const entry = process.argv[1];
|
|
441
|
+
return entry !== void 0 && pathToFileURL(realpathSync(entry)).href === import.meta.url;
|
|
442
|
+
}
|
|
443
|
+
if (isMainModule()) {
|
|
444
|
+
runToolkitCli(process.argv.slice(2), {
|
|
445
|
+
collect: collectFromThisMachine,
|
|
446
|
+
out: (text) => console.log(text),
|
|
447
|
+
err: (text) => console.error(text)
|
|
448
|
+
}).then(
|
|
449
|
+
// Exit explicitly: a process listing abandoned at its deadline would otherwise keep the event loop (and the budget) open.
|
|
450
|
+
(code) => process.stdout.write("", () => process.exit(code)),
|
|
451
|
+
(err) => {
|
|
452
|
+
console.error(errMessage(err));
|
|
453
|
+
process.exit(EXIT.failed);
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
export {
|
|
458
|
+
collectFromThisMachine,
|
|
459
|
+
runToolkitCli
|
|
460
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-5294 - run a mini app target's build command and fold what it wrote into ONE self-contained HTML document.
|
|
3
|
+
*
|
|
4
|
+
* A target names a build command (an argument list, run in the repo root with NO shell). Danxbot makes a fresh, empty temporary folder
|
|
5
|
+
* for each build and appends two arguments to the command: the design folder (repo-relative, forward slashes) and that output folder
|
|
6
|
+
* (absolute). The command writes `index.html` and its assets into the output folder; danxbot reads them and deletes the folder. The command
|
|
7
|
+
* is the child process: when it exits the OS releases everything it loaded, so the long-lived MCP server never holds a file of the design's
|
|
8
|
+
* checkout (DX-4727, kept). Nothing in the person's checkout is ever deleted, and no two builds share a folder.
|
|
9
|
+
*
|
|
10
|
+
* This module carries no validator for the document it produces: that the document is self-contained is checked by exactly ONE
|
|
11
|
+
* validator, the dashboard's own parse5 check on the revision route, whose 400 comes back to the caller verbatim.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { foldBuildOutput } from "./mini-app-output.js";
|
|
19
|
+
/** A build id unique per call, so two publishes never share a local page. */
|
|
20
|
+
export function newBuildId() {
|
|
21
|
+
return `${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
22
|
+
}
|
|
23
|
+
// ---------------- running the build ----------------
|
|
24
|
+
/** How long the build command may run before it is killed: a hung build must not hold the tool call (and the design's files) forever. */
|
|
25
|
+
export const BUILD_TIMEOUT_MS = 5 * 60 * 1000;
|
|
26
|
+
/** The most of the build's stdout+stderr kept (the tail, where a failure is): a chatty build must not grow this long-lived process without bound. */
|
|
27
|
+
export const MAX_BUILD_OUTPUT_CHARS = 1024 * 1024;
|
|
28
|
+
/** How long the build's output pipes may stay open after the build process itself has exited: a leftover child holding them is then killed. */
|
|
29
|
+
export const PIPE_GRACE_MS = 2000;
|
|
30
|
+
/** How often, and how far apart, the temporary output folder's delete is retried: a killed build's file handle can stay open on Windows for a moment. */
|
|
31
|
+
const OUTPUT_DELETE_RETRIES = 10;
|
|
32
|
+
const OUTPUT_DELETE_RETRY_MS = 200;
|
|
33
|
+
/**
|
|
34
|
+
* DX-5294: end the build's WHOLE process tree. The command is arbitrary repo code that may start children of its own, and a child that
|
|
35
|
+
* keeps the output pipes open would otherwise outlive the limit. Windows: `taskkill /T /F` (waited for, never blocking the event loop;
|
|
36
|
+
* it can only find children while the parent is still alive); POSIX: the child leads its own process group (spawned `detached`), and the
|
|
37
|
+
* whole group gets SIGKILL.
|
|
38
|
+
*/
|
|
39
|
+
async function killProcessTree(pid, child) {
|
|
40
|
+
if (pid === undefined)
|
|
41
|
+
return;
|
|
42
|
+
if (process.platform === "win32") {
|
|
43
|
+
const status = await new Promise((resolve) => {
|
|
44
|
+
const killer = spawn("taskkill", ["/T", "/F", "/PID", String(pid)], { windowsHide: true, stdio: "ignore" });
|
|
45
|
+
killer.once("error", () => resolve(null));
|
|
46
|
+
killer.once("close", (code) => resolve(code));
|
|
47
|
+
});
|
|
48
|
+
if (status !== 0)
|
|
49
|
+
child.kill();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
process.kill(-pid, "SIGKILL");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
child.kill("SIGKILL");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Run the target's build command in a fresh child (no shell, `cwd` the repo root, the design folder and the output folder as its last two arguments) and wait for
|
|
61
|
+
* it to exit. A failed build rejects with the tail of what the command wrote to stdout and stderr (verbatim unless it passed
|
|
62
|
+
* `MAX_BUILD_OUTPUT_CHARS`, which the message then says); a command still running after `timeoutMs` has its whole process tree killed
|
|
63
|
+
* and rejects with the output so far WITHOUT waiting for the pipes to close; a command that cannot be spawned rejects with the spawn
|
|
64
|
+
* error; warnings from a good build are relayed to this process's stderr. Once the process has exited, its pipes get `pipeGraceMs` to
|
|
65
|
+
* close; a leftover child still holding them is then killed (its process group on POSIX) and no longer waited for.
|
|
66
|
+
*/
|
|
67
|
+
export async function runBuildCommand(input, timeoutMs = BUILD_TIMEOUT_MS, pipeGraceMs = PIPE_GRACE_MS) {
|
|
68
|
+
const [program, ...args] = input.command;
|
|
69
|
+
const done = await new Promise((resolve, reject) => {
|
|
70
|
+
const child = spawn(program, [...args, input.designDirRel, input.outputDir], {
|
|
71
|
+
cwd: input.repoRoot,
|
|
72
|
+
shell: false,
|
|
73
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
detached: process.platform !== "win32",
|
|
76
|
+
});
|
|
77
|
+
let text = "";
|
|
78
|
+
let truncated = false;
|
|
79
|
+
let settled = false;
|
|
80
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
81
|
+
stream.setEncoding("utf8");
|
|
82
|
+
stream.on("data", (chunk) => {
|
|
83
|
+
text += chunk;
|
|
84
|
+
if (text.length > MAX_BUILD_OUTPUT_CHARS) {
|
|
85
|
+
text = text.slice(-MAX_BUILD_OUTPUT_CHARS);
|
|
86
|
+
truncated = true;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
let grace;
|
|
91
|
+
// The ONE way the wait ends: the first caller wins, both timers stop, and every later event is ignored. Returns false when already ended.
|
|
92
|
+
const finish = () => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return false;
|
|
95
|
+
settled = true;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
clearTimeout(grace);
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
// The limit or the pipe grace ran out: kill the whole tree and stop waiting on its pipes.
|
|
101
|
+
const stopWaiting = (outcome) => {
|
|
102
|
+
if (!finish())
|
|
103
|
+
return;
|
|
104
|
+
void killProcessTree(child.pid, child).finally(() => {
|
|
105
|
+
child.stdout.destroy();
|
|
106
|
+
child.stderr.destroy();
|
|
107
|
+
resolve({ ...outcome, text, truncated });
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
const timer = setTimeout(() => stopWaiting({ code: null, signal: null, timedOut: true }), timeoutMs);
|
|
111
|
+
child.once("exit", (code, signal) => {
|
|
112
|
+
grace = setTimeout(() => stopWaiting({ code, signal, timedOut: false }), pipeGraceMs);
|
|
113
|
+
});
|
|
114
|
+
child.once("error", (err) => {
|
|
115
|
+
if (finish())
|
|
116
|
+
reject(new Error(`mini_app_publish: could not start \`${program}\`: ${err.message}`));
|
|
117
|
+
});
|
|
118
|
+
child.once("close", (code, signal) => {
|
|
119
|
+
if (finish())
|
|
120
|
+
resolve({ code, signal, text, truncated, timedOut: false });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
const note = done.truncated ? `[output truncated: only the last ${MAX_BUILD_OUTPUT_CHARS} characters are shown]\n` : "";
|
|
124
|
+
const output = done.text.trim();
|
|
125
|
+
if (done.timedOut) {
|
|
126
|
+
throw new Error(`mini_app_publish: the build did not finish within ${timeoutMs / 1000}s and was killed${output === "" ? "" : `; its output so far:\n${note}${output}`}`);
|
|
127
|
+
}
|
|
128
|
+
if (done.code !== 0) {
|
|
129
|
+
const ended = done.signal !== null ? `was killed by ${done.signal}` : `exited with code ${String(done.code)}`;
|
|
130
|
+
throw new Error(output ? `${note}${output}` : `mini_app_publish: the build process ${ended} and wrote nothing`);
|
|
131
|
+
}
|
|
132
|
+
if (done.text !== "")
|
|
133
|
+
process.stderr.write(done.text);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Build the design: make a fresh temporary output folder, run the command with it, read the folder and fold it into one document, then
|
|
137
|
+
* delete the folder (success or not). If the run failed or timed out nothing is read.
|
|
138
|
+
*/
|
|
139
|
+
export async function buildMiniApp(input, timeoutMs = BUILD_TIMEOUT_MS, pipeGraceMs = PIPE_GRACE_MS) {
|
|
140
|
+
const outputDir = await mkdtemp(path.join(os.tmpdir(), "danx-mini-app-"));
|
|
141
|
+
try {
|
|
142
|
+
await runBuildCommand({ ...input, outputDir }, timeoutMs, pipeGraceMs);
|
|
143
|
+
return { html: await foldBuildOutput(outputDir), buildId: newBuildId() };
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
// A temp folder danxbot made: a failed delete (a killed build's file handle still open on Windows) leaks only that folder and must not discard the build.
|
|
147
|
+
await rm(outputDir, { recursive: true, force: true, maxRetries: OUTPUT_DELETE_RETRIES, retryDelay: OUTPUT_DELETE_RETRY_MS }).catch((err) => {
|
|
148
|
+
process.stderr.write(`mini_app_publish: could not delete the temporary output folder ${outputDir}: ${err.message}\n`);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** `<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. */
|
|
153
|
+
export async function writeLocalHtml(buildId, html) {
|
|
154
|
+
const dir = path.join(os.tmpdir(), "danx-design");
|
|
155
|
+
await mkdir(dir, { recursive: true });
|
|
156
|
+
const file = path.join(dir, `${buildId}.html`);
|
|
157
|
+
await writeFile(file, html, "utf8");
|
|
158
|
+
return file;
|
|
159
|
+
}
|