infernoflow 0.13.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/infernoflow.mjs +34 -0
- package/dist/lib/commands/changelog.mjs +255 -6
- package/dist/lib/commands/cloud.mjs +521 -0
- package/dist/lib/commands/dashboard.mjs +399 -0
- package/dist/lib/commands/onboard.mjs +296 -0
- package/dist/lib/commands/prComment.mjs +361 -0
- package/dist/lib/commands/teamSync.mjs +388 -0
- package/dist/templates/ci/github-pr-comment.yml +50 -0
- package/package.json +1 -1
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow dashboard
|
|
3
|
+
*
|
|
4
|
+
* Launches a local web server on http://localhost:7337 showing:
|
|
5
|
+
* - Contract health status
|
|
6
|
+
* - Capability list with add/remove/change history
|
|
7
|
+
* - Drift timeline (last N sessions)
|
|
8
|
+
* - Agent activity log
|
|
9
|
+
* - Auto-refresh via SSE (server-sent events)
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* infernoflow dashboard # open on port 7337
|
|
13
|
+
* infernoflow dashboard --port 8080 # custom port
|
|
14
|
+
* infernoflow dashboard --no-open # don't auto-open browser
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as http from "node:http";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import { execSync } from "node:child_process";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { header, ok, info, warn, bold, cyan, gray } from "../ui/output.mjs";
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
// ── data loaders ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function loadContract(infernoDir) {
|
|
30
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
31
|
+
if (!fs.existsSync(contractPath)) return null;
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(contractPath, "utf8")); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadCapabilities(infernoDir) {
|
|
36
|
+
for (const name of ["capabilities.json", "contract.json"]) {
|
|
37
|
+
const p = path.join(infernoDir, name);
|
|
38
|
+
if (!fs.existsSync(p)) continue;
|
|
39
|
+
try {
|
|
40
|
+
const obj = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
41
|
+
const raw = obj.capabilities || [];
|
|
42
|
+
return raw.map(c => typeof c === "string" ? { id: c, title: c } : c);
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadProfile(infernoDir) {
|
|
49
|
+
const p = path.join(infernoDir, "developer-profile.json");
|
|
50
|
+
if (!fs.existsSync(p)) return null;
|
|
51
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadAgents(infernoDir) {
|
|
55
|
+
const agentsDir = path.join(infernoDir, "agents");
|
|
56
|
+
if (!fs.existsSync(agentsDir)) return [];
|
|
57
|
+
return fs.readdirSync(agentsDir)
|
|
58
|
+
.filter(f => f.endsWith(".json"))
|
|
59
|
+
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(agentsDir, f), "utf8")); } catch { return null; } })
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadHookLog(infernoDir) {
|
|
64
|
+
const logPath = path.join(infernoDir, "HOOK.log");
|
|
65
|
+
if (!fs.existsSync(logPath)) return null;
|
|
66
|
+
try { return JSON.parse(fs.readFileSync(logPath, "utf8")); } catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runCheck(infernoDir) {
|
|
70
|
+
try {
|
|
71
|
+
const out = execSync("npx infernoflow check --json", {
|
|
72
|
+
cwd: path.dirname(infernoDir),
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
timeout: 15_000,
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
return JSON.parse(out);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
try { return JSON.parse(err.stdout || "{}"); } catch { return { status: "error", error: "check failed" }; }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function gatherData(infernoDir) {
|
|
84
|
+
const caps = loadCapabilities(infernoDir);
|
|
85
|
+
const contract = loadContract(infernoDir);
|
|
86
|
+
const profile = loadProfile(infernoDir);
|
|
87
|
+
const agents = loadAgents(infernoDir);
|
|
88
|
+
const hookLog = loadHookLog(infernoDir);
|
|
89
|
+
const check = runCheck(infernoDir);
|
|
90
|
+
const sessions = profile?.recentSessions?.slice(-10) || [];
|
|
91
|
+
const candidates = [
|
|
92
|
+
...(profile?.agentCandidates || []),
|
|
93
|
+
...(profile?.skillCandidates || []),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
return { caps, contract, agents, hookLog, check, sessions, candidates, infernoDir };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── HTML builder ──────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function buildHtml(data, projectName) {
|
|
102
|
+
const { caps, agents, check, sessions, candidates } = data;
|
|
103
|
+
|
|
104
|
+
const statusColor = check?.status === "ok" ? "#22c55e"
|
|
105
|
+
: check?.status === "warning" ? "#f59e0b"
|
|
106
|
+
: check?.status === "error" ? "#ef4444"
|
|
107
|
+
: "#6b7280";
|
|
108
|
+
|
|
109
|
+
const statusLabel = check?.status || "unknown";
|
|
110
|
+
const capCount = caps.length;
|
|
111
|
+
const agentCount = agents.length;
|
|
112
|
+
const issueCount = (check?.issues || []).length;
|
|
113
|
+
|
|
114
|
+
// Capability rows
|
|
115
|
+
const capRows = caps.map(c => {
|
|
116
|
+
const statusBadge = c.status ? `<span class="badge">${c.status}</span>` : "";
|
|
117
|
+
return `<tr>
|
|
118
|
+
<td><code>${esc(c.id)}</code></td>
|
|
119
|
+
<td>${esc(c.title || "")}${statusBadge}</td>
|
|
120
|
+
<td>${esc(c.since || "")}</td>
|
|
121
|
+
</tr>`;
|
|
122
|
+
}).join("\n");
|
|
123
|
+
|
|
124
|
+
// Agent rows
|
|
125
|
+
const agentRows = agents.map(a => {
|
|
126
|
+
const steps = (a.steps || []).map(s => typeof s === "string" ? s : s.command).join(" → ");
|
|
127
|
+
const conf = a.confidence ? `${Math.round(a.confidence * 100)}%` : "—";
|
|
128
|
+
return `<tr>
|
|
129
|
+
<td><strong>${esc(a.name)}</strong></td>
|
|
130
|
+
<td>${esc(a.description || steps)}</td>
|
|
131
|
+
<td><code>${esc(steps)}</code></td>
|
|
132
|
+
<td>${conf}</td>
|
|
133
|
+
</tr>`;
|
|
134
|
+
}).join("\n");
|
|
135
|
+
|
|
136
|
+
// Issues
|
|
137
|
+
const issueItems = (check?.issues || []).map(i =>
|
|
138
|
+
`<li class="issue">${esc(typeof i === "string" ? i : i.message || JSON.stringify(i))}</li>`
|
|
139
|
+
).join("\n");
|
|
140
|
+
|
|
141
|
+
// Session timeline
|
|
142
|
+
const sessionItems = sessions.slice().reverse().map(s => {
|
|
143
|
+
const cmds = (s.commands || []).join(", ");
|
|
144
|
+
const date = s.startedAt ? new Date(s.startedAt).toLocaleString() : "unknown";
|
|
145
|
+
return `<div class="session-item">
|
|
146
|
+
<span class="session-date">${esc(date)}</span>
|
|
147
|
+
<span class="session-cmds">${esc(cmds || "no commands recorded")}</span>
|
|
148
|
+
</div>`;
|
|
149
|
+
}).join("\n");
|
|
150
|
+
|
|
151
|
+
// Candidate suggestions
|
|
152
|
+
const candidateItems = candidates.map(c =>
|
|
153
|
+
`<li class="candidate">${esc(c.name || c.id || "unnamed")}: ${esc(c.description || "")}</li>`
|
|
154
|
+
).join("\n");
|
|
155
|
+
|
|
156
|
+
return `<!DOCTYPE html>
|
|
157
|
+
<html lang="en">
|
|
158
|
+
<head>
|
|
159
|
+
<meta charset="UTF-8">
|
|
160
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
161
|
+
<title>infernoflow — ${esc(projectName)}</title>
|
|
162
|
+
<style>
|
|
163
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
164
|
+
:root {
|
|
165
|
+
--bg: #0f1117; --surface: #1a1d27; --border: #2d3148;
|
|
166
|
+
--text: #e2e8f0; --muted: #64748b; --accent: #f97316;
|
|
167
|
+
--green: #22c55e; --yellow: #f59e0b; --red: #ef4444; --blue: #3b82f6;
|
|
168
|
+
}
|
|
169
|
+
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; font-size: 14px; line-height: 1.5; }
|
|
170
|
+
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
|
|
171
|
+
header h1 { font-size: 18px; font-weight: 700; }
|
|
172
|
+
header .flame { font-size: 22px; }
|
|
173
|
+
header .project { color: var(--muted); font-size: 13px; }
|
|
174
|
+
header .live { margin-left: auto; font-size: 11px; color: var(--green); display: flex; align-items: center; gap: 4px; }
|
|
175
|
+
header .live::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
|
|
176
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
177
|
+
main { max-width: 1100px; margin: 0 auto; padding: 24px; display: grid; gap: 20px; }
|
|
178
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
|
|
179
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 18px; }
|
|
180
|
+
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 8px; }
|
|
181
|
+
.card .value { font-size: 28px; font-weight: 700; }
|
|
182
|
+
.card .sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
|
183
|
+
.status-ok { color: var(--green); }
|
|
184
|
+
.status-warning { color: var(--yellow); }
|
|
185
|
+
.status-error { color: var(--red); }
|
|
186
|
+
section { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|
187
|
+
section h2 { font-size: 13px; font-weight: 600; padding: 14px 18px; border-bottom: 1px solid var(--border); color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
188
|
+
table { width: 100%; border-collapse: collapse; }
|
|
189
|
+
th, td { padding: 10px 18px; text-align: left; border-bottom: 1px solid var(--border); }
|
|
190
|
+
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); background: rgba(255,255,255,0.02); }
|
|
191
|
+
tr:last-child td { border-bottom: none; }
|
|
192
|
+
tr:hover td { background: rgba(255,255,255,0.03); }
|
|
193
|
+
code { font-family: monospace; font-size: 12px; background: rgba(255,255,255,0.07); padding: 1px 5px; border-radius: 4px; }
|
|
194
|
+
.badge { font-size: 10px; background: rgba(249,115,22,0.15); color: var(--accent); padding: 1px 6px; border-radius: 9px; margin-left: 6px; }
|
|
195
|
+
.issues-list, .candidates-list { list-style: none; padding: 14px 18px; display: flex; flex-direction: column; gap: 8px; }
|
|
196
|
+
.issue { color: var(--red); font-size: 13px; }
|
|
197
|
+
.candidate { color: var(--yellow); font-size: 13px; }
|
|
198
|
+
.empty { padding: 24px 18px; color: var(--muted); text-align: center; font-size: 13px; }
|
|
199
|
+
.session-item { display: flex; gap: 16px; align-items: baseline; padding: 9px 18px; border-bottom: 1px solid var(--border); }
|
|
200
|
+
.session-item:last-child { border-bottom: none; }
|
|
201
|
+
.session-date { font-size: 11px; color: var(--muted); white-space: nowrap; min-width: 140px; }
|
|
202
|
+
.session-cmds { font-size: 12px; color: var(--text); }
|
|
203
|
+
footer { text-align: center; color: var(--muted); font-size: 11px; padding: 24px; }
|
|
204
|
+
</style>
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
<header>
|
|
208
|
+
<span class="flame">🔥</span>
|
|
209
|
+
<div>
|
|
210
|
+
<h1>infernoflow</h1>
|
|
211
|
+
<div class="project">${esc(projectName)}</div>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="live">Live</div>
|
|
214
|
+
</header>
|
|
215
|
+
<main>
|
|
216
|
+
|
|
217
|
+
<!-- Stat cards -->
|
|
218
|
+
<div class="cards">
|
|
219
|
+
<div class="card">
|
|
220
|
+
<div class="label">Contract status</div>
|
|
221
|
+
<div class="value status-${statusLabel}" style="color:${statusColor}">${statusLabel.toUpperCase()}</div>
|
|
222
|
+
<div class="sub">${issueCount > 0 ? issueCount + " issue" + (issueCount !== 1 ? "s" : "") : "All checks passed"}</div>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="card">
|
|
225
|
+
<div class="label">Capabilities</div>
|
|
226
|
+
<div class="value">${capCount}</div>
|
|
227
|
+
<div class="sub">tracked in contract</div>
|
|
228
|
+
</div>
|
|
229
|
+
<div class="card">
|
|
230
|
+
<div class="label">Agents</div>
|
|
231
|
+
<div class="value">${agentCount}</div>
|
|
232
|
+
<div class="sub">synthesized workflows</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="card">
|
|
235
|
+
<div class="label">Sessions</div>
|
|
236
|
+
<div class="value">${sessions.length}</div>
|
|
237
|
+
<div class="sub">recent sessions logged</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
${issueCount > 0 ? `
|
|
242
|
+
<!-- Issues -->
|
|
243
|
+
<section>
|
|
244
|
+
<h2>⚠ Issues</h2>
|
|
245
|
+
<ul class="issues-list">${issueItems}</ul>
|
|
246
|
+
</section>` : ""}
|
|
247
|
+
|
|
248
|
+
<!-- Capabilities -->
|
|
249
|
+
<section>
|
|
250
|
+
<h2>Capabilities (${capCount})</h2>
|
|
251
|
+
${capCount > 0 ? `
|
|
252
|
+
<table>
|
|
253
|
+
<thead><tr><th>ID</th><th>Title</th><th>Since</th></tr></thead>
|
|
254
|
+
<tbody>${capRows}</tbody>
|
|
255
|
+
</table>` : `<div class="empty">No capabilities found in inferno/capabilities.json</div>`}
|
|
256
|
+
</section>
|
|
257
|
+
|
|
258
|
+
<!-- Agents -->
|
|
259
|
+
<section>
|
|
260
|
+
<h2>Synthesized Agents (${agentCount})</h2>
|
|
261
|
+
${agentCount > 0 ? `
|
|
262
|
+
<table>
|
|
263
|
+
<thead><tr><th>Name</th><th>Description</th><th>Steps</th><th>Confidence</th></tr></thead>
|
|
264
|
+
<tbody>${agentRows}</tbody>
|
|
265
|
+
</table>` : `<div class="empty">No agents yet — run <code>infernoflow synthesize</code> to generate them</div>`}
|
|
266
|
+
</section>
|
|
267
|
+
|
|
268
|
+
${candidates.length > 0 ? `
|
|
269
|
+
<!-- Candidates -->
|
|
270
|
+
<section>
|
|
271
|
+
<h2>Workflow Candidates (${candidates.length})</h2>
|
|
272
|
+
<ul class="candidates-list">${candidateItems}</ul>
|
|
273
|
+
</section>` : ""}
|
|
274
|
+
|
|
275
|
+
<!-- Session timeline -->
|
|
276
|
+
<section>
|
|
277
|
+
<h2>Recent Sessions</h2>
|
|
278
|
+
${sessions.length > 0 ? `<div>${sessionItems}</div>`
|
|
279
|
+
: `<div class="empty">No session data yet — sessions are logged automatically as you use infernoflow</div>`}
|
|
280
|
+
</section>
|
|
281
|
+
|
|
282
|
+
</main>
|
|
283
|
+
<footer>infernoflow dashboard · auto-refreshes every 10s · <a href="/" style="color:var(--muted)">refresh now</a></footer>
|
|
284
|
+
<script>
|
|
285
|
+
// SSE live reload
|
|
286
|
+
const es = new EventSource('/events');
|
|
287
|
+
es.onmessage = () => window.location.reload();
|
|
288
|
+
es.onerror = () => {};
|
|
289
|
+
</script>
|
|
290
|
+
</body>
|
|
291
|
+
</html>`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function esc(str) {
|
|
295
|
+
return String(str || "")
|
|
296
|
+
.replace(/&/g, "&")
|
|
297
|
+
.replace(/</g, "<")
|
|
298
|
+
.replace(/>/g, ">")
|
|
299
|
+
.replace(/"/g, """);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function startServer(infernoDir, port) {
|
|
305
|
+
const cwd = path.dirname(infernoDir);
|
|
306
|
+
const projectName = path.basename(cwd);
|
|
307
|
+
const sseClients = new Set();
|
|
308
|
+
|
|
309
|
+
// Watch inferno/ for changes → notify SSE clients
|
|
310
|
+
let watchTimer = null;
|
|
311
|
+
try {
|
|
312
|
+
fs.watch(infernoDir, { recursive: true }, () => {
|
|
313
|
+
clearTimeout(watchTimer);
|
|
314
|
+
watchTimer = setTimeout(() => {
|
|
315
|
+
for (const res of sseClients) {
|
|
316
|
+
try { res.write("data: reload\n\n"); } catch {}
|
|
317
|
+
}
|
|
318
|
+
}, 500);
|
|
319
|
+
});
|
|
320
|
+
} catch {}
|
|
321
|
+
|
|
322
|
+
const server = http.createServer((req, res) => {
|
|
323
|
+
// SSE endpoint
|
|
324
|
+
if (req.url === "/events") {
|
|
325
|
+
res.writeHead(200, {
|
|
326
|
+
"Content-Type": "text/event-stream",
|
|
327
|
+
"Cache-Control": "no-cache",
|
|
328
|
+
"Connection": "keep-alive",
|
|
329
|
+
});
|
|
330
|
+
sseClients.add(res);
|
|
331
|
+
req.on("close", () => sseClients.delete(res));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// JSON API
|
|
336
|
+
if (req.url === "/api/data") {
|
|
337
|
+
const data = gatherData(infernoDir);
|
|
338
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
339
|
+
res.end(JSON.stringify(data, null, 2));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Dashboard HTML
|
|
344
|
+
try {
|
|
345
|
+
const data = gatherData(infernoDir);
|
|
346
|
+
const html = buildHtml(data, projectName);
|
|
347
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
348
|
+
res.end(html);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
351
|
+
res.end(`Error: ${err.message}`);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
server.listen(port, "127.0.0.1", () => {});
|
|
356
|
+
return server;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function openBrowser(url) {
|
|
360
|
+
const platform = os.platform();
|
|
361
|
+
try {
|
|
362
|
+
if (platform === "darwin") execSync(`open "${url}"`, { stdio: "ignore" });
|
|
363
|
+
else if (platform === "win32") execSync(`start "" "${url}"`, { stdio: "ignore", shell: true });
|
|
364
|
+
else execSync(`xdg-open "${url}"`, { stdio: "ignore" });
|
|
365
|
+
} catch {}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
export async function dashboardCommand(rawArgs) {
|
|
371
|
+
const args = rawArgs.slice(1);
|
|
372
|
+
const noOpen = args.includes("--no-open");
|
|
373
|
+
const portIdx = args.indexOf("--port");
|
|
374
|
+
const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 7337;
|
|
375
|
+
|
|
376
|
+
const cwd = process.cwd();
|
|
377
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
378
|
+
|
|
379
|
+
header("infernoflow dashboard");
|
|
380
|
+
|
|
381
|
+
if (!fs.existsSync(infernoDir)) {
|
|
382
|
+
warn("inferno/ not found — run: infernoflow init");
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const url = `http://localhost:${port}`;
|
|
387
|
+
|
|
388
|
+
startServer(infernoDir, port);
|
|
389
|
+
|
|
390
|
+
ok(`Dashboard running → ${cyan(url)}`);
|
|
391
|
+
info("Auto-refreshes when inferno/ files change");
|
|
392
|
+
info("Press Ctrl+C to stop");
|
|
393
|
+
console.log();
|
|
394
|
+
|
|
395
|
+
if (!noOpen) openBrowser(url);
|
|
396
|
+
|
|
397
|
+
// Keep alive
|
|
398
|
+
await new Promise(() => {});
|
|
399
|
+
}
|