@stage5/lumine 0.2.92 → 0.2.95
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 +31 -0
- package/lib/admin-daily.js +338 -0
- package/lib/admin.js +544 -126
- package/lib/api.js +58 -0
- package/lib/app-mcp/server.js +135 -23
- package/lib/assets.js +93 -2
- package/lib/commands.js +18 -0
- package/lib/constants.js +5 -0
- package/lib/report-assets/marked.min.js +69 -0
- package/lib/util.js +28 -1
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +6 -4
- package/sdk/LUMINE_ADMIN.md +41 -0
package/README.md
CHANGED
|
@@ -51,6 +51,16 @@ returns the confirmed canonical limits after commit. Running the command again
|
|
|
51
51
|
is a safe no-op that reports the already-active canonical limits. Omit the
|
|
52
52
|
target to use the current workspace or selected project.
|
|
53
53
|
|
|
54
|
+
Lumine file storage (uploaded assets and `Twinkle.files`) is counted per
|
|
55
|
+
creator across all their Builds. `lumine assets storage` shows your limit, usage
|
|
56
|
+
and any pending request. Past the default quota, `lumine assets request-storage
|
|
57
|
+
[--size 500MB|1GB|2GB] [--reason "<what it is for>"]` asks Mikey for more (the
|
|
58
|
+
next tier up when `--size` is omitted); nothing changes until he approves it.
|
|
59
|
+
Mikey reviews with `lumine admin storage list`, `show <user>`,
|
|
60
|
+
`approve <request-id> [--size <size>]`, `reject <request-id> [--reason <text>]`,
|
|
61
|
+
or grants directly with `lumine admin storage grant <user> --size <size>`.
|
|
62
|
+
Approvals are capped at 2 GB and never lower a creator below the default.
|
|
63
|
+
|
|
54
64
|
For team projects, Lumine mirrors the website workspace flow: choosing or
|
|
55
65
|
pulling the owner's main project creates or reuses your contribution branch and
|
|
56
66
|
checks out that branch locally. Saves go to your branch, so the project owner
|
|
@@ -128,6 +138,27 @@ Discovery is static and fail-closed: runtime code cannot add tools that were
|
|
|
128
138
|
not declared in the pinned manifest, and a session refuses to connect if any
|
|
129
139
|
declared handler is missing.
|
|
130
140
|
|
|
141
|
+
### Connecting to a tab you already have open
|
|
142
|
+
|
|
143
|
+
In an app that offers tools, a signed-in viewer can click **Connect AI helper**
|
|
144
|
+
in the app toolbar (or the workspace preview toolbar). Twinkle shows a
|
|
145
|
+
six-character code for that open tab, valid for 10 minutes and usable once.
|
|
146
|
+
Then run:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
lumine app-mcp <app-url-or-id> --code ABC123
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The bridge attaches to that tab instead of opening a new one. The CLI must be
|
|
153
|
+
logged in as the same Twinkle account as the tab. The tab shows an
|
|
154
|
+
"AI helper connected" badge with a **Disconnect** button and an **Allow edits**
|
|
155
|
+
switch, which starts off: until the viewer switches it on, only tools declared
|
|
156
|
+
read-only in `/app-tools.json` (`"readOnly": true`, or
|
|
157
|
+
`"readOnlyWhen": { "action": ["list"] }` for one action argument) work.
|
|
158
|
+
Undeclared tools count as edits. Disconnecting, or leaving the app, ends the
|
|
159
|
+
session and resets the switch. `--helper-name <name>` overrides the label the
|
|
160
|
+
viewer sees (by default the MCP client's own name).
|
|
161
|
+
|
|
131
162
|
## Inspecting Build SDK data
|
|
132
163
|
|
|
133
164
|
`lumine sdk call <namespace.method> '<jsonArgs>'` calls a build's data SDK
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import http from "http";
|
|
4
|
+
import { spawn, spawnSync } from "child_process";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
|
|
7
|
+
// Multi-step conveniences for the full daily management run. Each one runs
|
|
8
|
+
// the ordinary `lumine admin` commands as child processes, so pagination,
|
|
9
|
+
// audit, scope checks and --output handling stay exactly as documented; these
|
|
10
|
+
// only remove the hand-typed glue that every run used to repeat.
|
|
11
|
+
|
|
12
|
+
const LUMINE_BIN = fileURLToPath(new URL("../bin/lumine.js", import.meta.url));
|
|
13
|
+
const MARKED_ASSET = fileURLToPath(
|
|
14
|
+
new URL("./report-assets/marked.min.js", import.meta.url),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
function runAdmin(args, { outputFile, extraEnv } = {}) {
|
|
18
|
+
const full = ["admin", ...args, "--json"];
|
|
19
|
+
if (outputFile) full.push("--output", outputFile);
|
|
20
|
+
const result = spawnSync(process.execPath, [LUMINE_BIN, ...full], {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
23
|
+
env: { ...process.env, ...(extraEnv || {}) },
|
|
24
|
+
});
|
|
25
|
+
let parsed = null;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(result.stdout || "null");
|
|
28
|
+
} catch {
|
|
29
|
+
parsed = null;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
ok: result.status === 0 && parsed?.ok !== false,
|
|
33
|
+
status: result.status,
|
|
34
|
+
json: parsed,
|
|
35
|
+
stderr: String(result.stderr || "").slice(-2000),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ensureDir(dir) {
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function utcDayKey(offsetDays = 0) {
|
|
45
|
+
const d = new Date(Date.now() + offsetDays * 86400000);
|
|
46
|
+
return d.toISOString().slice(0, 10);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Bangkok midnight `days` calendar days before today, as an ISO timestamp.
|
|
50
|
+
export function bangkokCutoff(days, now = Date.now()) {
|
|
51
|
+
const nowBkk = new Date(now + 7 * 3600000);
|
|
52
|
+
const midnight = Date.UTC(
|
|
53
|
+
nowBkk.getUTCFullYear(),
|
|
54
|
+
nowBkk.getUTCMonth(),
|
|
55
|
+
nowBkk.getUTCDate(),
|
|
56
|
+
);
|
|
57
|
+
return new Date(midnight - days * 86400000 - 7 * 3600000).toISOString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Renders every Zero/Ciel row as readable text grouped by conversation, the
|
|
61
|
+
// file the conduct review reads end to end.
|
|
62
|
+
export function renderBotOutput({ chatMessages, comments }, file) {
|
|
63
|
+
const t = (ts) => new Date(ts * 1000).toISOString().slice(5, 16);
|
|
64
|
+
const lines = [];
|
|
65
|
+
const sorted = [...chatMessages].sort(
|
|
66
|
+
(a, b) => a.channelId - b.channelId || a.messageId - b.messageId,
|
|
67
|
+
);
|
|
68
|
+
for (const m of sorted) {
|
|
69
|
+
const r = m.recipient || {};
|
|
70
|
+
const g = m.generation || {};
|
|
71
|
+
lines.push(
|
|
72
|
+
`--- #${m.messageId} ${t(m.occurredAt)} ${m.bot} -> ${r.username}(${r.userId}) ch${m.channelId} ${m.surface} kind=${m.messageKind} src=${m.source} gen=${g.storedOutcome} err=${g.errorType}`,
|
|
73
|
+
String(m.content ?? ""),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
lines.push("", "", "===== COMMENTS =====");
|
|
77
|
+
for (const c of comments) {
|
|
78
|
+
lines.push(
|
|
79
|
+
`--- comment ${c.commentId} ${t(c.occurredAt)} ${c.bot} root=${c.rootType}:${c.rootId} ${c.url}`,
|
|
80
|
+
String(c.content ?? ""),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function dailyRunGather(options) {
|
|
87
|
+
const dir = ensureDir(
|
|
88
|
+
path.resolve(options.adminOutputDir || options.adminDir || "."),
|
|
89
|
+
);
|
|
90
|
+
const yesterday = options.adminDate || utcDayKey(-1);
|
|
91
|
+
const steps = [];
|
|
92
|
+
const step = (name, args, file) => {
|
|
93
|
+
const outputFile = path.join(dir, file);
|
|
94
|
+
const res = runAdmin(args, { outputFile });
|
|
95
|
+
steps.push({
|
|
96
|
+
name,
|
|
97
|
+
file,
|
|
98
|
+
ok: res.ok,
|
|
99
|
+
...(res.ok ? {} : { error: res.json?.error || res.stderr }),
|
|
100
|
+
});
|
|
101
|
+
return res;
|
|
102
|
+
};
|
|
103
|
+
step("energy-budget", ["energy-budget"], "energy-budget.json");
|
|
104
|
+
step("news", ["news"], "news.json");
|
|
105
|
+
step("brief", ["brief"], "brief.json");
|
|
106
|
+
step("ai-costs-monthly", ["ai-costs", "monthly"], "ai-monthly.json");
|
|
107
|
+
step("ai-costs-day", ["ai-costs", "day", yesterday], "ai-day.json");
|
|
108
|
+
step("media-costs", ["media-costs", "monthly"], "media.json");
|
|
109
|
+
step(
|
|
110
|
+
"reward-activity",
|
|
111
|
+
["reward-activity", "--date", yesterday, "--days", "7"],
|
|
112
|
+
"reward-activity.json",
|
|
113
|
+
);
|
|
114
|
+
step("reward-review-pending", ["reward-review", "list"], "rr-pending.json");
|
|
115
|
+
step("todos", ["todo", "list"], "todos.json");
|
|
116
|
+
step("escalations", ["escalation", "list"], "escalations.json");
|
|
117
|
+
step("featured-list", ["featured", "list"], "featured-start.json");
|
|
118
|
+
step(
|
|
119
|
+
"sponsor-applications",
|
|
120
|
+
["sponsor", "applications", "list", "--status", "pending"],
|
|
121
|
+
"sponsor-applications.json",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Bot output: follow the cursor until both sources are exhausted.
|
|
125
|
+
const chatMessages = [];
|
|
126
|
+
const comments = [];
|
|
127
|
+
let cursor = "";
|
|
128
|
+
let pages = 0;
|
|
129
|
+
let botOk = true;
|
|
130
|
+
for (;;) {
|
|
131
|
+
pages += 1;
|
|
132
|
+
const file = `bot-output-${pages}.json`;
|
|
133
|
+
const res = step(
|
|
134
|
+
`bot-output-page-${pages}`,
|
|
135
|
+
cursor ? ["bot-output", "--cursor", cursor] : ["bot-output"],
|
|
136
|
+
file,
|
|
137
|
+
);
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
botOk = false;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
const data = res.json?.data || {};
|
|
143
|
+
chatMessages.push(...(data.chatMessages || []));
|
|
144
|
+
comments.push(...(data.comments || []));
|
|
145
|
+
const p = data.pagination || {};
|
|
146
|
+
if (p.exhausted && !data.chatTruncated && !data.commentsTruncated) break;
|
|
147
|
+
cursor = p.nextCursor || "";
|
|
148
|
+
if (!cursor || pages > 50) {
|
|
149
|
+
botOk = false;
|
|
150
|
+
steps.push({
|
|
151
|
+
name: "bot-output",
|
|
152
|
+
ok: false,
|
|
153
|
+
error: "pagination did not exhaust",
|
|
154
|
+
});
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (botOk) renderBotOutput({ chatMessages, comments }, path.join(dir, "bot-chats.txt"));
|
|
159
|
+
|
|
160
|
+
const summary = {
|
|
161
|
+
dir,
|
|
162
|
+
yesterdayUtc: yesterday,
|
|
163
|
+
botOutput: {
|
|
164
|
+
complete: botOk,
|
|
165
|
+
pages,
|
|
166
|
+
chatRows: chatMessages.length,
|
|
167
|
+
comments: comments.length,
|
|
168
|
+
readFile: botOk ? path.join(dir, "bot-chats.txt") : null,
|
|
169
|
+
},
|
|
170
|
+
steps,
|
|
171
|
+
failed: steps.filter((s) => !s.ok).map((s) => s.name),
|
|
172
|
+
};
|
|
173
|
+
fs.writeFileSync(
|
|
174
|
+
path.join(dir, "gather.json"),
|
|
175
|
+
JSON.stringify(summary, null, 2),
|
|
176
|
+
{ mode: 0o600 },
|
|
177
|
+
);
|
|
178
|
+
return {
|
|
179
|
+
ok: summary.failed.length === 0,
|
|
180
|
+
status: summary.failed.length ? "partial_failure" : "success",
|
|
181
|
+
data: summary,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Recent, provably never-Featured Subjects with the context the editorial
|
|
186
|
+
// ranking needs (comment count, operator view state), newest first.
|
|
187
|
+
export async function featuredCandidates(options) {
|
|
188
|
+
const days = Math.max(1, Math.min(30, Number(options.adminDays || 7)));
|
|
189
|
+
const after = options.adminAfter || bangkokCutoff(days);
|
|
190
|
+
const scratch = ensureDir(
|
|
191
|
+
path.resolve(options.adminOutputDir || fs.mkdtempSync(path.join(process.env.TMPDIR || "/tmp", "lumine-featured-"))),
|
|
192
|
+
);
|
|
193
|
+
const subjects = runAdmin(
|
|
194
|
+
["subjects", "candidates", "--after", after, "--all"],
|
|
195
|
+
{ outputFile: path.join(scratch, "subjects.json") },
|
|
196
|
+
);
|
|
197
|
+
if (!subjects.ok) throw new Error(`subjects candidates failed: ${JSON.stringify(subjects.json?.error || subjects.stderr)}`);
|
|
198
|
+
const board = runAdmin(["featured", "list"]);
|
|
199
|
+
if (!board.ok) throw new Error("featured list failed");
|
|
200
|
+
const onBoard = new Set((board.json.data.subjects || []).map((s) => s.id));
|
|
201
|
+
const recent = (subjects.json.data.subjects || []).filter((s) => !onBoard.has(s.id));
|
|
202
|
+
const ids = recent.map((s) => s.id);
|
|
203
|
+
const history = ids.length
|
|
204
|
+
? runAdmin(["featured", "history", "--subject-ids", ids.join(","), "--all"])
|
|
205
|
+
: { ok: true, json: { data: { subjects: [], coverage: {} } } };
|
|
206
|
+
if (!history.ok) throw new Error("featured history failed");
|
|
207
|
+
const never = new Map(
|
|
208
|
+
(history.json.data.subjects || []).map((s) => [s.id, s.neverFeatured]),
|
|
209
|
+
);
|
|
210
|
+
const candidates = [];
|
|
211
|
+
for (const s of recent) {
|
|
212
|
+
const detail = runAdmin(["subject", "get", String(s.id), "--include-comments"]);
|
|
213
|
+
const subject = detail.json?.data?.subject || {};
|
|
214
|
+
const humanComments = (subject.comments || []).filter(
|
|
215
|
+
(c) => !c.isNotification && ![7587, 8411].includes(Number(c.author?.id)),
|
|
216
|
+
);
|
|
217
|
+
candidates.push({
|
|
218
|
+
id: s.id,
|
|
219
|
+
url: s.url,
|
|
220
|
+
title: s.title,
|
|
221
|
+
author: s.author?.username || null,
|
|
222
|
+
createdAt: s.createdAt,
|
|
223
|
+
neverFeatured: never.get(s.id) ?? null,
|
|
224
|
+
effortLevel: s.effortLevel,
|
|
225
|
+
hasSecret: Boolean(s.secret?.hasSecret),
|
|
226
|
+
hasAttachment: Boolean(s.attachment),
|
|
227
|
+
description: String(s.description || "").slice(0, 300),
|
|
228
|
+
commentsIncluded: Boolean(subject.commentsIncluded),
|
|
229
|
+
humanComments: humanComments.length,
|
|
230
|
+
botComments: (subject.comments || []).filter((c) =>
|
|
231
|
+
[7587, 8411].includes(Number(c.author?.id)),
|
|
232
|
+
).length,
|
|
233
|
+
operatorViewed: Boolean(s.operatorViewed?.viewed),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
status: "success",
|
|
239
|
+
data: {
|
|
240
|
+
postedAfter: after,
|
|
241
|
+
coverage: history.json.data.coverage || null,
|
|
242
|
+
boardCount: onBoard.size,
|
|
243
|
+
eligible: candidates.filter((c) => c.neverFeatured === true),
|
|
244
|
+
excluded: candidates
|
|
245
|
+
.filter((c) => c.neverFeatured !== true)
|
|
246
|
+
.map((c) => ({ id: c.id, title: c.title, neverFeatured: c.neverFeatured })),
|
|
247
|
+
note: "Eligibility only: posted after the cutoff and provably never Featured. Ranking by likely member engagement is the editor's judgment.",
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function reportHtml(title) {
|
|
253
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title.replace(/</g, "<")}</title>
|
|
254
|
+
<style>
|
|
255
|
+
:root{--bg:#fff;--fg:#1d2433;--muted:#5b6475;--line:#dde2ea;--nav:#f6f8fb;--code:#f1f3f7;--link:#2757c9}
|
|
256
|
+
@media (prefers-color-scheme:dark){:root{--bg:#14171d;--fg:#e4e8ef;--muted:#9aa3b2;--line:#2b313b;--nav:#1a1e25;--code:#222833;--link:#8fb0ff}}
|
|
257
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
258
|
+
nav{position:fixed;top:0;left:0;bottom:0;width:250px;overflow:auto;background:var(--nav);border-right:1px solid var(--line);padding:18px 14px}
|
|
259
|
+
nav h4{margin:0 0 10px;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}nav a{display:block;padding:4px 6px;border-radius:6px;color:var(--fg);text-decoration:none;font-size:13px}nav a:hover{background:var(--line)}
|
|
260
|
+
main{margin-left:250px;padding:28px 40px;max-width:1100px}a{color:var(--link)}table{border-collapse:collapse;margin:10px 0;display:block;overflow-x:auto}th,td{border:1px solid var(--line);padding:6px 9px;text-align:left;vertical-align:top}
|
|
261
|
+
code{background:var(--code);padding:1px 5px;border-radius:4px;font-size:.9em}pre{background:var(--code);padding:12px;border-radius:8px;overflow:auto}h2{border-bottom:1px solid var(--line);padding-bottom:4px;margin-top:34px}
|
|
262
|
+
@media (max-width:760px){nav{position:static;width:auto;height:auto;border-right:0;border-bottom:1px solid var(--line)}main{margin-left:0;padding:16px}}
|
|
263
|
+
</style></head><body><nav><h4>Sections</h4><div id="toc"></div><h4 style="margin-top:18px">Source</h4><a href="daily-management-report.md">Markdown</a></nav><main id="content">Loading…</main>
|
|
264
|
+
<script src="marked.min.js"></script><script>
|
|
265
|
+
fetch('daily-management-report.md',{cache:'no-store'}).then(r=>r.text()).then(md=>{const el=document.getElementById('content');el.innerHTML=marked.parse(md);const toc=document.getElementById('toc');let i=0;el.querySelectorAll('h1,h2').forEach(h=>{h.id='s'+(i++);if(h.tagName==='H2'){const a=document.createElement('a');a.href='#'+h.id;a.textContent=h.textContent;toc.appendChild(a)}})});
|
|
266
|
+
</script></body></html>`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Serves the report Markdown as a browsable page on loopback. Only the page,
|
|
270
|
+
// its script and the Markdown are reachable; the evidence folder is not.
|
|
271
|
+
export async function reportServe(options) {
|
|
272
|
+
const file = path.resolve(options.adminFile || "daily-management-report.md");
|
|
273
|
+
if (!fs.existsSync(file)) throw new Error(`Report not found: ${file}`);
|
|
274
|
+
const title =
|
|
275
|
+
(fs.readFileSync(file, "utf8").match(/^#\s+(.+)$/m) || [])[1] ||
|
|
276
|
+
"Daily management report";
|
|
277
|
+
const port = Number(options.adminPort || 0);
|
|
278
|
+
const routes = {
|
|
279
|
+
"/": () => ({ type: "text/html; charset=utf-8", body: reportHtml(title) }),
|
|
280
|
+
"/index.html": () => ({ type: "text/html; charset=utf-8", body: reportHtml(title) }),
|
|
281
|
+
"/marked.min.js": () => ({ type: "text/javascript", body: fs.readFileSync(MARKED_ASSET) }),
|
|
282
|
+
"/daily-management-report.md": () => ({
|
|
283
|
+
type: "text/markdown; charset=utf-8",
|
|
284
|
+
body: fs.readFileSync(file),
|
|
285
|
+
}),
|
|
286
|
+
};
|
|
287
|
+
if (options.adminForeground) {
|
|
288
|
+
const server = http.createServer((req, res) => {
|
|
289
|
+
const route = routes[(req.url || "/").split("?")[0]];
|
|
290
|
+
if (!route) {
|
|
291
|
+
res.writeHead(404).end("not found");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const { type, body } = route();
|
|
295
|
+
res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
|
|
296
|
+
res.end(body);
|
|
297
|
+
});
|
|
298
|
+
await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
|
|
299
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
300
|
+
console.log(JSON.stringify({ ok: true, status: "success", data: { url, file } }));
|
|
301
|
+
return new Promise(() => {});
|
|
302
|
+
}
|
|
303
|
+
// Detach a foreground copy of this command so the page outlives the CLI.
|
|
304
|
+
const child = spawn(
|
|
305
|
+
process.execPath,
|
|
306
|
+
[LUMINE_BIN, "admin", "report", "serve", "--file", file, "--port", String(port), "--foreground"],
|
|
307
|
+
{ detached: true, stdio: ["ignore", "pipe", "ignore"] },
|
|
308
|
+
);
|
|
309
|
+
const url = await new Promise((resolve, reject) => {
|
|
310
|
+
let buf = "";
|
|
311
|
+
const timer = setTimeout(() => reject(new Error("report server did not start")), 10000);
|
|
312
|
+
child.stdout.on("data", (chunk) => {
|
|
313
|
+
buf += chunk;
|
|
314
|
+
const line = buf.split("\n")[0];
|
|
315
|
+
try {
|
|
316
|
+
const parsed = JSON.parse(line);
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
resolve(parsed.data.url);
|
|
319
|
+
} catch {
|
|
320
|
+
/* wait for the full line */
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
child.on("exit", (code) => {
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
reject(new Error(`report server exited (${code})`));
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
child.stdout.destroy();
|
|
329
|
+
child.unref();
|
|
330
|
+
if (options.adminOpen) {
|
|
331
|
+
spawnSync("open", ["-a", "Google Chrome", url]);
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
status: "success",
|
|
336
|
+
data: { url, file, pid: child.pid, opened: Boolean(options.adminOpen) },
|
|
337
|
+
};
|
|
338
|
+
}
|