pi-central 0.3.0 → 0.4.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/README.md +58 -17
- package/bin/pi-central.js +143 -10
- package/bin/pi-central.js.map +4 -4
- package/dist/ui/apple-touch-icon.png +0 -0
- package/dist/ui/assets/{grammar-map-D-x_DcfQ.js → grammar-map-CcZdLTyL.js} +1 -1
- package/dist/ui/assets/index-BC2CSxKc.js +40 -0
- package/dist/ui/assets/index-SAdkZfHm.css +1 -0
- package/dist/ui/assets/logo-icon-BCSgj5RT.svg +23 -0
- package/dist/ui/assets/logo-icon-CYTM3sb_.png +0 -0
- package/dist/ui/icon-192.png +0 -0
- package/dist/ui/icon-512.png +0 -0
- package/dist/ui/index.html +33 -3
- package/dist/ui/manifest.webmanifest +30 -0
- package/dist/ui/sw.js +93 -0
- package/package.json +1 -1
- package/dist/ui/assets/index-CDs5_ZFh.js +0 -40
- package/dist/ui/assets/index-Deb-EFhq.css +0 -1
package/README.md
CHANGED
|
@@ -1,18 +1,43 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/pi-central-logo-full-dark-mode.svg" alt="Pi Central" width="420" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
>
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/pi-central"><img alt="npm version" src="https://img.shields.io/npm/v/pi-central" /></a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/pi-central"><img alt="npm downloads" src="https://img.shields.io/npm/dm/pi-central" /></a>
|
|
8
|
+
<a href="https://github.com/sven1103-agent/pi-central/actions/workflows/ci.yml"><img alt="CI status" src="https://github.com/sven1103-agent/pi-central/actions/workflows/ci.yml/badge.svg" /></a>
|
|
9
|
+
<img alt="node version" src="https://img.shields.io/node/v/pi-central" />
|
|
10
|
+
<img alt="license" src="https://img.shields.io/npm/l/pi-central" />
|
|
11
|
+
</p>
|
|
4
12
|
|
|
5
|
-
Pi Central is a local dashboard for running, watching, and switching between
|
|
6
|
-
[Pi](https://pi.dev) coding-agent sessions that live in **different working directories**,
|
|
7
|
-
keeping every session inside its own **Agent Safehouse** sandbox.
|
|
13
|
+
**Pi Central** (/paɪ ˈsɛntrəl/) is a local dashboard for running, watching, and switching between
|
|
14
|
+
multiple [Pi](https://pi.dev) coding-agent sessions that live in **different working directories**,
|
|
15
|
+
while keeping every session inside its own **Agent Safehouse** sandbox.
|
|
8
16
|
|
|
9
|
-
The
|
|
10
|
-
|
|
17
|
+
The central station is the design brief: independent lines arrive, are dispatched, and are kept apart
|
|
18
|
+
from one another. Pi Central does the same for agent sessions.
|
|
11
19
|
|
|
12
20
|
**Status: works end to end, verified, and published.** The version is deliberately not written down
|
|
13
21
|
here: `pi-central --version` reports the version of the package it was actually shipped in, and a
|
|
14
22
|
number restated in prose is a number that drifts. See [docs/RELEASING.md](docs/RELEASING.md).
|
|
15
23
|
|
|
24
|
+
## Install it as an app
|
|
25
|
+
|
|
26
|
+
Pi Central is a web app that installs to your dock. Start it once (see [Quick start](#quick-start)),
|
|
27
|
+
open <http://127.0.0.1:8787>, and then install it from the browser's own menu:
|
|
28
|
+
|
|
29
|
+
| Browser | How |
|
|
30
|
+
|---|---|
|
|
31
|
+
| **Safari** | *File → Add to Dock* |
|
|
32
|
+
| **Chrome / Edge** | the install icon at the right end of the address bar, or *⋮ → Install page as app* |
|
|
33
|
+
| **Firefox** | **not supported** — Mozilla has no PWA install on desktop. The service worker still works; use another browser for the dock icon. |
|
|
34
|
+
|
|
35
|
+
You get the π mark in the dock and a window with no tab bar or address field.
|
|
36
|
+
|
|
37
|
+
**It is not an offline app, deliberately.** Every screen is fed by the socket, so if the orchestrator is
|
|
38
|
+
not running the installed app says `disconnected` and nothing else. A window that claimed to work
|
|
39
|
+
offline would open onto a blank dashboard.
|
|
40
|
+
|
|
16
41
|
## The problem
|
|
17
42
|
|
|
18
43
|
Pi has no fleet concept. One `pi` process owns one session and one working directory:
|
|
@@ -79,7 +104,29 @@ never runs untrusted content, so being outside the sandbox is safe by constructi
|
|
|
79
104
|
|
|
80
105
|
**One process.** The orchestrator serves the built UI and the WebSocket from the same origin, so
|
|
81
106
|
there is no dev server to start and no port to coordinate. It must run from a plain terminal (see
|
|
82
|
-
the constraint above).
|
|
107
|
+
the constraint above), and it prints the URL it is serving on — <http://127.0.0.1:8787> unless you
|
|
108
|
+
passed `--port`.
|
|
109
|
+
|
|
110
|
+
### From npm — what a user does
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm install --global pi-central
|
|
114
|
+
pi-central
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or without installing it at all:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx pi-central
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Once it is running, [install it as an app](#install-it-as-an-app) and you will not need the terminal
|
|
124
|
+
again until the next reboot.
|
|
125
|
+
|
|
126
|
+
The package is **one bundled ESM file plus the built UI**, with a single runtime dependency (`ws`),
|
|
127
|
+
and `pi-central --version` reports the version of the package it was actually shipped in.
|
|
128
|
+
|
|
129
|
+
### From a checkout — what a contributor does
|
|
83
130
|
|
|
84
131
|
```bash
|
|
85
132
|
npm install
|
|
@@ -87,11 +134,7 @@ npm run ui:build # once, and after any UI change
|
|
|
87
134
|
npm run server # the orchestrator, which also serves the UI
|
|
88
135
|
```
|
|
89
136
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
### From the packaged artifact
|
|
93
|
-
|
|
94
|
-
Nothing is published yet, but the artifact is buildable and installable today (issue #52):
|
|
137
|
+
To build the publishable artifact from a checkout:
|
|
95
138
|
|
|
96
139
|
```bash
|
|
97
140
|
npm run build:package # bundles the orchestrator, copies the built UI
|
|
@@ -99,10 +142,8 @@ npm pack --dry-run # show exactly what would ship
|
|
|
99
142
|
npm run serve:package # run the packaged binary from ./bin
|
|
100
143
|
```
|
|
101
144
|
|
|
102
|
-
It ships as JavaScript — TypeScript cannot be loaded from `node_modules` — as
|
|
103
|
-
|
|
104
|
-
version of the package it was actually shipped in, read from that `package.json` rather than
|
|
105
|
-
restated in code. See [ADR-0011](docs/decisions/0011-distribution-and-update.md).
|
|
145
|
+
It ships as JavaScript — TypeScript cannot be loaded from `node_modules` — as one bundled ESM file
|
|
146
|
+
plus the built UI. See [ADR-0011](docs/decisions/0011-distribution-and-update.md).
|
|
106
147
|
|
|
107
148
|
Serving the UI from the orchestrator is not only convenience. A Vite dev server watches
|
|
108
149
|
`packages/ui/src`, so an agent editing a component reloads the interface you are using out from
|
package/bin/pi-central.js
CHANGED
|
@@ -25,7 +25,7 @@ function isBlockingDialog(record) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
// packages/protocol/src/envelopes.ts
|
|
28
|
-
var PROTOCOL_VERSION =
|
|
28
|
+
var PROTOCOL_VERSION = 3;
|
|
29
29
|
|
|
30
30
|
// packages/protocol/src/own-installation.ts
|
|
31
31
|
var PIPELINE_MARKERS = ["packages/orchestrator", "packages/ui", "packages/protocol"];
|
|
@@ -1132,6 +1132,7 @@ function renderPolicyFragment(options = {}) {
|
|
|
1132
1132
|
import { lstatSync, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync, statSync as statSync4 } from "node:fs";
|
|
1133
1133
|
import { isAbsolute, join as join7, resolve as resolve6, sep as sep3 } from "node:path";
|
|
1134
1134
|
var MAX_FILE_BYTES = 256 * 1024;
|
|
1135
|
+
var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
1135
1136
|
var BINARY_SNIFF_BYTES = 8 * 1024;
|
|
1136
1137
|
var DEFAULT_ENTRY_LIMIT = 2e3;
|
|
1137
1138
|
function containsPath(parent, child) {
|
|
@@ -1311,7 +1312,7 @@ function readFileText(root, relative) {
|
|
|
1311
1312
|
ok: false,
|
|
1312
1313
|
error: {
|
|
1313
1314
|
reason: "binary",
|
|
1314
|
-
message: `"${relPath}" is a binary file (it contains NUL bytes), so it was not rendered as text`
|
|
1315
|
+
message: `"${relPath}" is a binary file of ${formatBytes(stats.size)} (it contains NUL bytes), so it was not rendered as text`
|
|
1315
1316
|
}
|
|
1316
1317
|
};
|
|
1317
1318
|
}
|
|
@@ -1323,11 +1324,77 @@ function readFileText(root, relative) {
|
|
|
1323
1324
|
ok: false,
|
|
1324
1325
|
error: {
|
|
1325
1326
|
reason: "binary",
|
|
1326
|
-
message: `"${relPath}" is not valid UTF-8, so it was not rendered as text`
|
|
1327
|
+
message: `"${relPath}" is ${formatBytes(stats.size)} and not valid UTF-8, so it was not rendered as text`
|
|
1327
1328
|
}
|
|
1328
1329
|
};
|
|
1329
1330
|
}
|
|
1330
|
-
return { ok: true, value: { path: relPath, text, bytes: buffer.byteLength } };
|
|
1331
|
+
return { ok: true, value: { kind: "text", path: relPath, text, bytes: buffer.byteLength } };
|
|
1332
|
+
}
|
|
1333
|
+
function imageTypeOf(buffer) {
|
|
1334
|
+
const starts = (...signature) => signature.every((byte, index) => buffer[index] === byte);
|
|
1335
|
+
if (starts(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
|
|
1336
|
+
if (starts(255, 216, 255)) return "image/jpeg";
|
|
1337
|
+
if (starts(71, 73, 70, 56)) return "image/gif";
|
|
1338
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
1339
|
+
return "image/webp";
|
|
1340
|
+
}
|
|
1341
|
+
return void 0;
|
|
1342
|
+
}
|
|
1343
|
+
function svgRootOf(text) {
|
|
1344
|
+
let rest = text.slice(0, SVG_HEAD_BYTES);
|
|
1345
|
+
for (; ; ) {
|
|
1346
|
+
const next = rest.replace(/^\s+|^<\?xml[^>]*\?>|^<!--[\s\S]*?-->|^<!DOCTYPE[^>[]*(?:\[[\s\S]*?\])?\s*>/gi, "");
|
|
1347
|
+
if (next === rest) break;
|
|
1348
|
+
rest = next;
|
|
1349
|
+
}
|
|
1350
|
+
if (!/^<svg[\s>]/i.test(rest)) return false;
|
|
1351
|
+
return /xmlns\s*=\s*["']http:\/\/www\.w3\.org\/2000\/svg["']/i.test(rest);
|
|
1352
|
+
}
|
|
1353
|
+
var SVG_HEAD_BYTES = 8 * 1024;
|
|
1354
|
+
function readFileForDisplay(root, relative) {
|
|
1355
|
+
const resolvedPath = resolveWithin(root, relative);
|
|
1356
|
+
if (!resolvedPath.ok) return resolvedPath;
|
|
1357
|
+
const { path, relPath } = resolvedPath.value;
|
|
1358
|
+
let stats;
|
|
1359
|
+
try {
|
|
1360
|
+
stats = lstatSync(path);
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
return { ok: false, error: fromErrno(error, "cannot open", relPath) };
|
|
1363
|
+
}
|
|
1364
|
+
if (stats.isDirectory()) {
|
|
1365
|
+
return { ok: false, error: { reason: "is-directory", message: `"${relPath}" is a directory` } };
|
|
1366
|
+
}
|
|
1367
|
+
if (stats.size > MAX_IMAGE_BYTES) {
|
|
1368
|
+
return {
|
|
1369
|
+
ok: false,
|
|
1370
|
+
error: {
|
|
1371
|
+
reason: "too-large",
|
|
1372
|
+
message: `"${relPath}" is ${formatBytes(stats.size)}, above the ${formatBytes(MAX_IMAGE_BYTES)} limit for displaying a file here, so it was not read`
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
let buffer;
|
|
1377
|
+
try {
|
|
1378
|
+
buffer = readFileSync4(path);
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
return { ok: false, error: fromErrno(error, "cannot read", relPath) };
|
|
1381
|
+
}
|
|
1382
|
+
const imageType = imageTypeOf(buffer);
|
|
1383
|
+
if (imageType !== void 0) {
|
|
1384
|
+
return {
|
|
1385
|
+
ok: true,
|
|
1386
|
+
value: { kind: "image", path: relPath, type: imageType, bytes: buffer.byteLength, data: buffer }
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
const asText = readFileText(root, relative);
|
|
1390
|
+
if (!asText.ok) return asText;
|
|
1391
|
+
if (svgRootOf(asText.value.text)) {
|
|
1392
|
+
return {
|
|
1393
|
+
ok: true,
|
|
1394
|
+
value: { kind: "image", path: relPath, type: "image/svg+xml", bytes: stats.size, data: buffer }
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
return asText;
|
|
1331
1398
|
}
|
|
1332
1399
|
|
|
1333
1400
|
// packages/orchestrator/src/state.ts
|
|
@@ -1858,7 +1925,7 @@ ${rpc.stderrTail}` : ""}`;
|
|
|
1858
1925
|
error: { reason: "not-found", message: `no session ${id}, so there is no working directory to read from` }
|
|
1859
1926
|
};
|
|
1860
1927
|
}
|
|
1861
|
-
return
|
|
1928
|
+
return readFileForDisplay(session.cwd, path);
|
|
1862
1929
|
}
|
|
1863
1930
|
/**
|
|
1864
1931
|
* Where this Pi Central installation lives, resolved once.
|
|
@@ -2079,7 +2146,15 @@ function directoryExists(dir) {
|
|
|
2079
2146
|
import { createReadStream as createReadStream2, existsSync as existsSync6, statSync as statSync6 } from "node:fs";
|
|
2080
2147
|
import { extname, join as join11, posix, resolve as resolve7, sep as sep4 } from "node:path";
|
|
2081
2148
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
2082
|
-
var UI_CSP =
|
|
2149
|
+
var UI_CSP = (
|
|
2150
|
+
// `blob:` is REQUIRED and is not covered by `'self'` (issue #111).
|
|
2151
|
+
//
|
|
2152
|
+
// A blob URL has an opaque origin, so `img-src 'self'` does not match it — the file viewer builds
|
|
2153
|
+
// its images from a Blob and would have every one of them blocked, with the `<img>` firing `error`
|
|
2154
|
+
// and the UI reporting "could not decode". That is a false accusation about a valid file, and it is
|
|
2155
|
+
// what shipped: the comment said a blob URL "counts as 'self'", which stopped anyone checking.
|
|
2156
|
+
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
2157
|
+
);
|
|
2083
2158
|
var CONTENT_TYPES = {
|
|
2084
2159
|
".html": "text/html; charset=utf-8",
|
|
2085
2160
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -2096,7 +2171,13 @@ var CONTENT_TYPES = {
|
|
|
2096
2171
|
".ico": "image/x-icon",
|
|
2097
2172
|
".woff": "font/woff",
|
|
2098
2173
|
".woff2": "font/woff2",
|
|
2099
|
-
".txt": "text/plain; charset=utf-8"
|
|
2174
|
+
".txt": "text/plain; charset=utf-8",
|
|
2175
|
+
// A manifest served as `application/octet-stream` is IGNORED by every browser, silently — no icon, no
|
|
2176
|
+
// install prompt, no error anywhere. The failure mode is a feature that simply does not exist.
|
|
2177
|
+
".webmanifest": "application/manifest+json",
|
|
2178
|
+
// The service worker must be JavaScript, and must not be cached: a stale worker is the one artefact that
|
|
2179
|
+
// can outlive a rebuild indefinitely (issues #119, #120).
|
|
2180
|
+
".webmanifest.json": "application/manifest+json"
|
|
2100
2181
|
};
|
|
2101
2182
|
function resolveUiDistDir(env = process.env, from = import.meta.url) {
|
|
2102
2183
|
const explicit = env["PI_CENTRAL_UI_DIST"];
|
|
@@ -2549,8 +2630,8 @@ var Transport = class {
|
|
|
2549
2630
|
sessionId: control.sessionId,
|
|
2550
2631
|
// As sent, for the same reason as `files`: one key for the client's bookkeeping.
|
|
2551
2632
|
path: control.path,
|
|
2552
|
-
|
|
2553
|
-
|
|
2633
|
+
bytes: result.value.bytes,
|
|
2634
|
+
content: result.value.kind === "image" ? { kind: "image", type: result.value.type, base64: result.value.data.toString("base64") } : { kind: "text", text: result.value.text }
|
|
2554
2635
|
});
|
|
2555
2636
|
} else {
|
|
2556
2637
|
this.#broadcastFileError(control.sessionId, control.path, "read", result.error);
|
|
@@ -2634,6 +2715,54 @@ var Transport = class {
|
|
|
2634
2715
|
}
|
|
2635
2716
|
};
|
|
2636
2717
|
|
|
2718
|
+
// packages/orchestrator/src/startup-error.ts
|
|
2719
|
+
import { readFileSync as readFileSync6, statSync as statSync7 } from "node:fs";
|
|
2720
|
+
import { join as join12 } from "node:path";
|
|
2721
|
+
function explainStartupError(error, host, port) {
|
|
2722
|
+
const code = error?.code;
|
|
2723
|
+
if (code === "EADDRINUSE") {
|
|
2724
|
+
return [
|
|
2725
|
+
`Port ${port} is already in use on ${host}.`,
|
|
2726
|
+
``,
|
|
2727
|
+
`That is almost always a Pi Central that is already running \u2014 and it is why this one did not`,
|
|
2728
|
+
`start. If you were expecting the code you just rebuilt to be served, you are probably looking at`,
|
|
2729
|
+
`the OLD process: its pages are still being served, and Node has its old modules cached.`,
|
|
2730
|
+
``,
|
|
2731
|
+
` Find it: lsof -ti tcp:${port}`,
|
|
2732
|
+
` Stop it: kill $(lsof -ti tcp:${port})`,
|
|
2733
|
+
` Or use another port: --port <n>`
|
|
2734
|
+
].join("\n");
|
|
2735
|
+
}
|
|
2736
|
+
if (code === "EACCES") {
|
|
2737
|
+
return [
|
|
2738
|
+
`Permission denied binding ${host}:${port}.`,
|
|
2739
|
+
``,
|
|
2740
|
+
`Ports below 1024 need elevated privileges on most systems, and Pi Central has no reason to want`,
|
|
2741
|
+
`one \u2014 it is a local tool. Use a port above 1024: --port 8788`
|
|
2742
|
+
].join("\n");
|
|
2743
|
+
}
|
|
2744
|
+
if (code === "EADDRNOTAVAIL") {
|
|
2745
|
+
return [
|
|
2746
|
+
`${host} is not an address this machine has.`,
|
|
2747
|
+
``,
|
|
2748
|
+
`Pi Central binds loopback only (C5), so the host should be 127.0.0.1. A wrong host is usually a`,
|
|
2749
|
+
`typo in --host or in PI_CENTRAL_HOST.`
|
|
2750
|
+
].join("\n");
|
|
2751
|
+
}
|
|
2752
|
+
return void 0;
|
|
2753
|
+
}
|
|
2754
|
+
function describeBuiltUi(uiDir) {
|
|
2755
|
+
try {
|
|
2756
|
+
const html = readFileSync6(join12(uiDir, "index.html"), "utf8");
|
|
2757
|
+
const entry = /assets\/index-[A-Za-z0-9_-]+\.js/.exec(html)?.[0];
|
|
2758
|
+
if (entry === void 0) return "(no entry bundle named in index.html)";
|
|
2759
|
+
const { mtime } = statSync7(join12(uiDir, "index.html"));
|
|
2760
|
+
return `${entry} (built ${mtime.toISOString().slice(0, 16).replace("T", " ")})`;
|
|
2761
|
+
} catch {
|
|
2762
|
+
return "(unreadable)";
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2637
2766
|
// packages/orchestrator/src/sandbox-guard.ts
|
|
2638
2767
|
import { spawnSync } from "node:child_process";
|
|
2639
2768
|
function probeSandbox(execPath = "/usr/bin/sandbox-exec") {
|
|
@@ -2703,6 +2832,7 @@ Pi Central starts sessions with \`pi\` under Agent Safehouse. Run it from a PLAI
|
|
|
2703
2832
|
Seatbelt does not nest, so a sandboxed shell cannot start a sandboxed session (C1).`);
|
|
2704
2833
|
}
|
|
2705
2834
|
var DEFAULT_PORT = 8787;
|
|
2835
|
+
var startupPort;
|
|
2706
2836
|
function parseArgs(argv) {
|
|
2707
2837
|
const options = {
|
|
2708
2838
|
port: DEFAULT_PORT,
|
|
@@ -2744,6 +2874,7 @@ function parseArgs(argv) {
|
|
|
2744
2874
|
}
|
|
2745
2875
|
async function main() {
|
|
2746
2876
|
const options = parseArgs(process.argv.slice(2));
|
|
2877
|
+
startupPort = options.port;
|
|
2747
2878
|
if (options.showVersion) {
|
|
2748
2879
|
console.log(versionLine());
|
|
2749
2880
|
return 0;
|
|
@@ -2789,6 +2920,7 @@ async function main() {
|
|
|
2789
2920
|
console.log(`UI: not served (--no-ui). Use a dev server, or drop the flag to serve a build.`);
|
|
2790
2921
|
} else if (hasBuiltUi(options.uiDir)) {
|
|
2791
2922
|
console.log(`Open ${httpUrl} \u2014 the UI is served from ${options.uiDir}`);
|
|
2923
|
+
console.log(` UI build: ${describeBuiltUi(options.uiDir)}`);
|
|
2792
2924
|
} else {
|
|
2793
2925
|
console.log(
|
|
2794
2926
|
`No built UI at ${options.uiDir}
|
|
@@ -2829,7 +2961,8 @@ main().then(
|
|
|
2829
2961
|
if (code !== 0) process.exit(code);
|
|
2830
2962
|
},
|
|
2831
2963
|
(error) => {
|
|
2832
|
-
|
|
2964
|
+
const explained = explainStartupError(error, "127.0.0.1", startupPort ?? 0);
|
|
2965
|
+
console.error(explained ?? `Pi Central failed to start: ${error.message}`);
|
|
2833
2966
|
process.exit(1);
|
|
2834
2967
|
}
|
|
2835
2968
|
);
|