privateer-agent 0.12.7 → 0.12.10
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 +29 -10
- package/SECURITY.md +41 -5
- package/bin/privateer-launch.mjs +85 -5
- package/bin/privateer-splash.mjs +247 -0
- package/bin/privateer-verify.d.mts +10 -0
- package/bin/privateer-verify.mjs +289 -0
- package/bin/update-route.d.mts +6 -0
- package/bin/update-route.mjs +35 -0
- package/extensions/privateer-brand.ts +35 -24
- package/extensions/privateer-hints.ts +8 -2
- package/extensions/privateer-update.ts +187 -0
- package/package.json +25 -25
- package/patches/{@earendil-works+pi-coding-agent+0.80.3.patch → @earendil-works+pi-coding-agent+0.84.1.patch} +221 -132
- package/src/acp/run.ts +5 -4
- package/src/channels/run.ts +3 -1
- package/src/cli/chat.ts +7 -5
- package/src/config/moatManifest.json +1 -0
- package/src/context.ts +9 -1
- package/src/harbor/index.ts +7 -6
- package/src/providers/account.ts +37 -29
- package/src/providers/piAuthStore.ts +129 -0
- package/src/remote/liveTaskSession.ts +6 -6
- package/src/session.ts +26 -9
- package/src/updates.ts +106 -0
package/README.md
CHANGED
|
@@ -12,9 +12,6 @@
|
|
|
12
12
|
<a href="https://www.npmjs.com/package/privateer-agent">
|
|
13
13
|
<img src="https://img.shields.io/npm/v/privateer-agent" alt="npm" />
|
|
14
14
|
</a>
|
|
15
|
-
<a href="https://www.npmjs.com/package/privateer-agent">
|
|
16
|
-
<img src="https://img.shields.io/npm/dm/privateer-agent" alt="npm downloads" />
|
|
17
|
-
</a>
|
|
18
15
|
<a href="https://github.com/privateer-agent/privateer-agent/releases">
|
|
19
16
|
<img src="https://img.shields.io/badge/changelog-what's%20new-5b8def" alt="Changelog" />
|
|
20
17
|
</a>
|
|
@@ -125,7 +122,7 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
125
122
|
shots stay continuous, cut them together, then score and narrate the result. Generated media
|
|
126
123
|
is handed straight back as files on your machine; none of it is stored in our cloud. See
|
|
127
124
|
[docs/media-generation.md](docs/media-generation.md).
|
|
128
|
-
- **Talk to it.** `/speak on` reads answers aloud as they're written; **
|
|
125
|
+
- **Talk to it.** `/speak on` reads answers aloud as they're written; **alt+t** is push-to-talk
|
|
129
126
|
and the mic closes when you stop talking. Your OS voice by default — nothing leaves the
|
|
130
127
|
machine — or your account's confidential-compute TTS/STT once you sign in. `/talk loop on`
|
|
131
128
|
makes it hands-free. See [Talk to it](#talk-to-it--voice-both-directions).
|
|
@@ -187,9 +184,31 @@ npm view privateer-agent dist.attestations # published from CI with npm proven
|
|
|
187
184
|
npm audit signatures # verify registry signatures + provenance
|
|
188
185
|
```
|
|
189
186
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
187
|
+
And **after** installing — the check nobody else offers, because every signal above is
|
|
188
|
+
an install-time one:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
privateer verify # is the install on THIS disk still the one we published?
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
It reports the install shape, whether this exact version is published and carries a
|
|
195
|
+
provenance attestation, whether any dependency has drifted from its pinned version,
|
|
196
|
+
and which launch-time patches are applied. Inconclusive checks say so rather than
|
|
197
|
+
counting as a pass; `--offline` skips the ones that need the registry.
|
|
198
|
+
|
|
199
|
+
The package also declares **no install scripts** — no `postinstall`, nothing — so
|
|
200
|
+
nothing we publish executes at install time, and `--ignore-scripts` gives an identical
|
|
201
|
+
result. That is a claim about *our* tarball, not your whole install: a handful of
|
|
202
|
+
packages in the ~500-package dependency tree do declare install hooks, and none of them
|
|
203
|
+
are needed here, so the install we recommend and test is
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
npm install -g privateer-agent --ignore-scripts
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Or skip the dependency graph entirely with a bundle (the `curl | sh` one-liner above),
|
|
210
|
+
which verifies a published SHA-256 and, from the next release on, a signed build
|
|
211
|
+
attestation before it unpacks anything.
|
|
193
212
|
|
|
194
213
|
See [SECURITY.md](SECURITY.md) for the threat model, the permission gate, and how to
|
|
195
214
|
report a vulnerability.
|
|
@@ -295,11 +314,11 @@ with `/signout`; manage linked terminals from the app.
|
|
|
295
314
|
## Talk to it — voice, both directions
|
|
296
315
|
|
|
297
316
|
`/speak on` and answers are read aloud **as they are written**, sentence by sentence, with
|
|
298
|
-
code blocks, tables and URLs stripped. Press **
|
|
317
|
+
code blocks, tables and URLs stripped. Press **alt+t** and talk; the mic closes when you
|
|
299
318
|
stop talking and the transcript lands in the composer for you to read before Enter sends it.
|
|
300
319
|
|
|
301
320
|
```
|
|
302
|
-
/speak on read answers aloud
|
|
321
|
+
/speak on read answers aloud alt+t push to talk (press again to send)
|
|
303
322
|
/speak voice <name> pick a speaker /talk the same thing, typed
|
|
304
323
|
/speak rate <n> 0.5–3× pace /talk loop on conversation mode, hands-free
|
|
305
324
|
/speak stream off wait for the full answer instead of speaking as it arrives
|
|
@@ -610,7 +629,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
610
629
|
| `/signin` · `/signout` | sign in to a Privateer account (device flow) / sign out |
|
|
611
630
|
| `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
|
|
612
631
|
| `/connect` · `/mcp` | add, enable, or remove MCP connectors / see what actually connected |
|
|
613
|
-
| `/speak` · `/talk` | read answers aloud / voice input (**
|
|
632
|
+
| `/speak` · `/talk` | read answers aloud / voice input (**alt+t** is push-to-talk) |
|
|
614
633
|
| `/extensions` | list loaded Pi extensions |
|
|
615
634
|
| `/init` | scaffold a starter `PRIVATEER.md` in this directory |
|
|
616
635
|
| `/update` · `/privateer` | update to the latest release / Privateer status and posture |
|
package/SECURITY.md
CHANGED
|
@@ -12,11 +12,27 @@ Privateer is a terminal coding agent: it reads and writes files, runs shell comm
|
|
|
12
12
|
talks to model providers on your behalf. That is the point of it, and it is also the
|
|
13
13
|
threat model. Two things are worth verifying rather than taking on faith.
|
|
14
14
|
|
|
15
|
-
**
|
|
16
|
-
`postinstall` or `prepare` hook
|
|
17
|
-
|
|
18
|
-
runs only when you run `privateer`. (Dependency patching happens at first launch —
|
|
19
|
-
`bin/apply-patches.mjs` and `docs/shipping.md`.)
|
|
15
|
+
**This package runs no install scripts.** There is no `preinstall`, `install`,
|
|
16
|
+
`postinstall` or `prepare` hook in `privateer-agent`: nothing we publish executes at
|
|
17
|
+
install time, and installing with `--ignore-scripts` produces an identical result. Our
|
|
18
|
+
code runs only when you run `privateer`. (Dependency patching happens at first launch —
|
|
19
|
+
see `bin/apply-patches.mjs` and `docs/shipping.md`.)
|
|
20
|
+
|
|
21
|
+
That is a claim about *our* tarball, not about your whole install, and the difference
|
|
22
|
+
matters. `npm install -g privateer-agent` resolves a tree of roughly 500 packages, and
|
|
23
|
+
seven of them do declare install scripts — `esbuild`, `koffi`, `fsevents`, `protobufjs`
|
|
24
|
+
(×2) and `@google/genai` (×2). On a default npm config those execute on your machine, as
|
|
25
|
+
they would for anything else that depends on them. Every one is a build-from-source or
|
|
26
|
+
compatibility-warning fallback we don't need, so install with scripts off:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g privateer-agent --ignore-scripts
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That is the install we test and the shape CI publishes from, and it produces a working
|
|
33
|
+
`privateer`. To skip the dependency graph altogether, use a release bundle
|
|
34
|
+
(`curl -fsSL https://privateer.pro/install.sh | sh`): a fixed set of files with a pinned
|
|
35
|
+
Node, no resolution step on your machine at all.
|
|
20
36
|
|
|
21
37
|
**Releases carry npm provenance.** Published from `.github/workflows/release.yml` with
|
|
22
38
|
`npm publish --provenance`, so npm holds a signed Sigstore attestation binding the
|
|
@@ -29,6 +45,26 @@ npm view privateer-agent dist.attestations # attestation metadata exists
|
|
|
29
45
|
npm audit signatures # verifies registry signatures + provenance
|
|
30
46
|
```
|
|
31
47
|
|
|
48
|
+
**You can re-check the install later.** Everything above is an install-time signal, which
|
|
49
|
+
is not much use to someone asking the question weeks afterwards. `privateer verify` reads
|
|
50
|
+
the install actually on disk and reports the shape it took, whether this version is
|
|
51
|
+
published with provenance, whether any dependency has drifted from its pinned version,
|
|
52
|
+
and which launch-time patches are applied. It distinguishes "verified" from "couldn't
|
|
53
|
+
check" and never reports the second as the first. It is not a security boundary — anything
|
|
54
|
+
that can rewrite `node_modules` can rewrite the checker — but it catches the accidental
|
|
55
|
+
and opportunistic cases, which is nearly all of them.
|
|
56
|
+
|
|
57
|
+
**Bundle downloads are verified before they unpack.** `install.sh` / `install.ps1` fetch
|
|
58
|
+
the release's published SHA-256 and refuse to install on a mismatch, a missing digest, or
|
|
59
|
+
a machine with no way to compute one (`PRIVATEER_SKIP_CHECKSUM=1` overrides, loudly). A
|
|
60
|
+
checksum served from the same origin as the file proves integrity but not provenance, so
|
|
61
|
+
from the release following this change the bundles also carry a GitHub build attestation,
|
|
62
|
+
which the installers verify automatically when `gh` is available:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
gh attestation verify privateer-darwin-arm64.tar.gz --repo privateer-agent/privateer-agent
|
|
66
|
+
```
|
|
67
|
+
|
|
32
68
|
If a version lacks provenance, it did not come from this workflow. Treat that as
|
|
33
69
|
suspicious and report it — with one documented exception: **0.6.7 is the first release
|
|
34
70
|
published this way.** Trusted publishing was misconfigured until then, so every earlier
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -26,8 +26,9 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import os from "node:os";
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
30
|
import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
31
|
+
import { routeUpdate } from "./update-route.mjs";
|
|
31
32
|
|
|
32
33
|
const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
|
|
33
34
|
const REPO = path.resolve(HERE, "..");
|
|
@@ -202,10 +203,9 @@ function updateNpmPackage() {
|
|
|
202
203
|
});
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
if (sub === "update") {
|
|
206
|
+
// Update the CLI itself: fetch the latest release and exit. Bundle installs re-run the
|
|
207
|
+
// download+extract installer; npm installs update the global package.
|
|
208
|
+
function updateSelf() {
|
|
209
209
|
if (BUNDLED) {
|
|
210
210
|
// PRIVATEER_UPDATE=1 flips the installer into update mode: weigh-anchor banner,
|
|
211
211
|
// "X → Y" version reporting, and an early exit (no download) when already current.
|
|
@@ -222,6 +222,67 @@ if (sub === "update") {
|
|
|
222
222
|
// both paths exit via their child's exit handler.
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
// Update TOOL PACKS (Pi "packages": npm/git sources contributing extensions, skills,
|
|
226
|
+
// prompts, themes) by handing the job to Pi's package-manager CLI, which owns npm/git
|
|
227
|
+
// installs, scopes and project trust. `then` runs only on a clean exit — that's how
|
|
228
|
+
// `--all` chains packs → self without either half being silently skipped.
|
|
229
|
+
//
|
|
230
|
+
// PI_CODING_AGENT_DIR is NOT optional here: without it Pi resolves the agent dir to a
|
|
231
|
+
// standalone ~/.pi/agent and would update packages belonging to a different install
|
|
232
|
+
// entirely, leaving the Privateer terminal's own packs untouched (and reporting success).
|
|
233
|
+
function updatePacks(cliArgs, then) {
|
|
234
|
+
const CLI = resolveDep(REPO, "@earendil-works/pi-coding-agent", "dist", "cli.js");
|
|
235
|
+
if (!CLI || !fs.existsSync(CLI)) {
|
|
236
|
+
console.error(
|
|
237
|
+
"privateer: couldn't find pi-coding-agent — the install looks incomplete.\n" +
|
|
238
|
+
" Try reinstalling: npm install -g privateer-agent@latest",
|
|
239
|
+
);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
ensurePatches(); // project `.privateer/` config dirs are a patch; -l scope needs them
|
|
243
|
+
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
244
|
+
const env = { ...process.env, PI_CODING_AGENT_DIR: AGENT_DIR };
|
|
245
|
+
const child = spawn(NODE_BIN, [...nodeArgs, CLI, "update", ...cliArgs], { stdio: "inherit", env });
|
|
246
|
+
child.on("exit", (code, signal) => {
|
|
247
|
+
if (signal) process.kill(process.pid, signal);
|
|
248
|
+
else if (code === 0 && then) then();
|
|
249
|
+
else process.exit(code ?? 0);
|
|
250
|
+
});
|
|
251
|
+
child.on("error", (e) => {
|
|
252
|
+
console.error(`privateer: failed to launch the package manager — ${e.message}`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- `privateer update [--extensions | --all | <pack>]` --------------------
|
|
258
|
+
// Two different things wear the same verb — the CLI itself and the tool packs — so the
|
|
259
|
+
// grammar and the reasoning behind it live in bin/update-route.mjs, next to its test.
|
|
260
|
+
// Tool packs can also be updated from inside a running terminal with /update, with no
|
|
261
|
+
// restart at all; see extensions/privateer-update.ts.
|
|
262
|
+
if (sub === "update") {
|
|
263
|
+
const rest = args.slice(1);
|
|
264
|
+
const route = routeUpdate(rest);
|
|
265
|
+
if (route === "help") {
|
|
266
|
+
const cmd = process.env.PRIVATEER_CMD || "privateer";
|
|
267
|
+
console.log(
|
|
268
|
+
[
|
|
269
|
+
`${cmd} update — fetch the latest release, or newer tool packs.`,
|
|
270
|
+
"",
|
|
271
|
+
` ${cmd} update update the Privateer CLI itself`,
|
|
272
|
+
` ${cmd} update --extensions update every installed tool pack`,
|
|
273
|
+
` ${cmd} update <pack> update one pack (npm name or git URL)`,
|
|
274
|
+
` ${cmd} update --all tool packs, then the CLI`,
|
|
275
|
+
"",
|
|
276
|
+
"Inside a running terminal, /update fetches tool packs in place — no restart.",
|
|
277
|
+
].join("\n"),
|
|
278
|
+
);
|
|
279
|
+
process.exit(0);
|
|
280
|
+
}
|
|
281
|
+
if (route === "all") updatePacks(["--extensions"], updateSelf);
|
|
282
|
+
else if (route === "packs") updatePacks(rest);
|
|
283
|
+
else updateSelf();
|
|
284
|
+
}
|
|
285
|
+
|
|
225
286
|
// --- `privateer harbor [run|install|uninstall|status]` ---------------------
|
|
226
287
|
// The resident background harbor (routines + app-driven headless task spawns). Boots
|
|
227
288
|
// straight into src/harbor via bin/privateer-harbor.mjs — the harbor loads the moat as
|
|
@@ -233,6 +294,16 @@ else if (sub === "harbor" || sub === "daemon") {
|
|
|
233
294
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)]);
|
|
234
295
|
}
|
|
235
296
|
|
|
297
|
+
// --- `privateer verify` ----------------------------------------------------
|
|
298
|
+
// Check the install that is on this disk right now. Every other trust signal we
|
|
299
|
+
// publish is an install-time one (npm provenance at `npm i`, a checksum inside
|
|
300
|
+
// install.sh, a badge on a web page nobody revisits); this is the one a user can
|
|
301
|
+
// run afterwards, on the machine they're worried about. Runs BEFORE ensurePatches
|
|
302
|
+
// so it can report the real patch state rather than the state it just created.
|
|
303
|
+
else if (sub === "verify") {
|
|
304
|
+
runToCompletion(NODE_BIN, [path.join(REPO, "bin", "privateer-verify.mjs"), ...args.slice(1)]);
|
|
305
|
+
}
|
|
306
|
+
|
|
236
307
|
// --- `privateer acp` -------------------------------------------------------
|
|
237
308
|
// Privateer as an Agent Client Protocol server, spawned by an ACP host (Buzz's
|
|
238
309
|
// `buzz-acp`, Zed, …) and driven over newline-delimited JSON-RPC on stdio.
|
|
@@ -402,6 +473,15 @@ else {
|
|
|
402
473
|
|
|
403
474
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
404
475
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
476
|
+
|
|
477
|
+
// The boot splash. `--import` so it runs before Pi's entry module — most of the wait
|
|
478
|
+
// it covers IS that module graph loading, so a splash started any later would miss it.
|
|
479
|
+
// TUI branch only: harbor/acp/subagent children have no terminal to animate on (and
|
|
480
|
+
// acp's stdout is a JSON-RPC stream). pathToFileURL, not the bare path — a Windows
|
|
481
|
+
// absolute path reads as the URL scheme "d:"; see bin/privateer.mjs for the same trap.
|
|
482
|
+
const splash = path.join(HERE, "privateer-splash.mjs");
|
|
483
|
+
if (fs.existsSync(splash)) nodeArgs.push("--import", pathToFileURL(splash).href);
|
|
484
|
+
|
|
405
485
|
runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...modelArgs, ...extArgs, ...skillArgs, ...args]);
|
|
406
486
|
}
|
|
407
487
|
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Boot splash — the wave that runs while Pi's TUI is still coming up.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. A cold `privateer` measured ~30s between the shell prompt and the
|
|
4
|
+
// first painted frame, all of it silent:
|
|
5
|
+
//
|
|
6
|
+
// 0.1s launcher banner (the login/keyless notice) — the last thing the user sees
|
|
7
|
+
// ~11s Pi's own module graph loads (bare `pi` with zero extensions costs this)
|
|
8
|
+
// ~19s …plus the 16 moat/tool-pack extensions we pass as `-e`
|
|
9
|
+
// ~30s session_start handlers finish and the TUI paints its first frame
|
|
10
|
+
//
|
|
11
|
+
// Worse than the wait is its shape: Pi enables raw mode and HIDES THE CURSOR at the
|
|
12
|
+
// ~19s mark, so the last third is a terminal with no prompt, no cursor and no output.
|
|
13
|
+
// Every report of this reads as "privateer hangs".
|
|
14
|
+
//
|
|
15
|
+
// Loaded with `node --import` (see the TUI branch of bin/privateer-launch.mjs) so it
|
|
16
|
+
// runs BEFORE Pi's entry module — the module loading it covers is most of the wait, and
|
|
17
|
+
// a splash started any later would miss it.
|
|
18
|
+
//
|
|
19
|
+
// WHY A WORKER THREAD. The first version of this drew from a setInterval and animated
|
|
20
|
+
// nothing: Pi's boot is a synchronous module-loading storm (compileSourceTextModule,
|
|
21
|
+
// readFileUtf8, the CJS lexer — see the profile), so the main thread's event loop never
|
|
22
|
+
// gets a turn between here and the first frame. A timer that only fires once the wait is
|
|
23
|
+
// over is not a loading indicator. The animation therefore lives on its own thread, with
|
|
24
|
+
// its own loop, which keeps drawing while the main thread is wedged. The main thread
|
|
25
|
+
// still owns the two things that must be synchronous — noticing Pi's output and getting
|
|
26
|
+
// out of its way — and steers the worker through a SharedArrayBuffer.
|
|
27
|
+
//
|
|
28
|
+
// HOW IT KNOWS WHEN TO STOP. Nothing tells us "the TUI is up", so we watch Pi's own
|
|
29
|
+
// output: process.stdout is patched here, before Pi ever touches it. Two signals:
|
|
30
|
+
// • `\x1b[?2004h` (bracketed paste) — TUI.start(). Raw mode is on, the cursor is
|
|
31
|
+
// hidden, extensions' session_start handlers are now running. The wave switches to
|
|
32
|
+
// its second message and keeps going; the first frame is still seconds away.
|
|
33
|
+
// • ≥ FRAME_BYTES of stdout AFTER that point — the first frame is being written. We
|
|
34
|
+
// stop the worker, erase the line and get out of the way in the same tick, before
|
|
35
|
+
// the frame reaches the terminal.
|
|
36
|
+
// Anything else Pi writes (a stray log line) just clears our line first, so the wave
|
|
37
|
+
// never lands in front of real output.
|
|
38
|
+
//
|
|
39
|
+
// The wave is drawn on STDERR; stdout belongs to the TUI's canvas.
|
|
40
|
+
|
|
41
|
+
import { Worker } from "node:worker_threads";
|
|
42
|
+
|
|
43
|
+
const enabled =
|
|
44
|
+
process.stdout.isTTY &&
|
|
45
|
+
process.stderr.isTTY &&
|
|
46
|
+
!process.env.PRIVATEER_NO_SPLASH &&
|
|
47
|
+
!process.env.CI;
|
|
48
|
+
|
|
49
|
+
// Bytes of stdout after TUI.start() that mean "this is the first frame, not a control
|
|
50
|
+
// sequence". Everything Pi writes between raw mode and the frame is short (the paste
|
|
51
|
+
// toggle, a Kitty protocol query, the cursor hide, an OSC window title — 42 bytes all
|
|
52
|
+
// told on the run this was measured from); the frame itself is thousands.
|
|
53
|
+
const FRAME_BYTES = 200;
|
|
54
|
+
|
|
55
|
+
// Nothing is shown for this long. A launch that fails fast (`--help`, a flag Pi
|
|
56
|
+
// rejects, a broken install) is done well inside it and never sees a wave flash across
|
|
57
|
+
// its output.
|
|
58
|
+
const HOLD_MS = 600;
|
|
59
|
+
|
|
60
|
+
if (enabled) {
|
|
61
|
+
const err = process.stderr;
|
|
62
|
+
const errWrite = err.write.bind(err);
|
|
63
|
+
const outWrite = process.stdout.write.bind(process.stdout);
|
|
64
|
+
|
|
65
|
+
// Shared state, the only channel to the drawing thread. Slots, in order: stop
|
|
66
|
+
// requested / which message to draw / the worker has stopped and will not write
|
|
67
|
+
// again / the worker has drawn at least once (so there is a line to erase and a
|
|
68
|
+
// hidden cursor to restore).
|
|
69
|
+
const STOP = 0, PHASE = 1, ACK = 2, DREW = 3;
|
|
70
|
+
const sab = new SharedArrayBuffer(4 * Int32Array.BYTES_PER_ELEMENT);
|
|
71
|
+
const state = new Int32Array(sab);
|
|
72
|
+
|
|
73
|
+
// Room for " ⚓ " + wave + message + elapsed, clamped so a narrow terminal doesn't
|
|
74
|
+
// wrap (a wrapped line survives our `\r\x1b[K` erase only on its last row).
|
|
75
|
+
const width = Math.max(12, Math.min(28, (err.columns || 80) - 34));
|
|
76
|
+
|
|
77
|
+
// The worker source is plain logic with no escape sequences of its own — every ANSI
|
|
78
|
+
// string is handed over in workerData, so nothing here has to survive two rounds of
|
|
79
|
+
// backslash escaping.
|
|
80
|
+
const worker = new Worker(
|
|
81
|
+
`
|
|
82
|
+
const fs = require("node:fs");
|
|
83
|
+
const { workerData: w } = require("node:worker_threads");
|
|
84
|
+
const s = new Int32Array(w.sab);
|
|
85
|
+
const STOP = 0, PHASE = 1, ACK = 2, DREW = 3;
|
|
86
|
+
const t0 = Date.now();
|
|
87
|
+
let frame = 0;
|
|
88
|
+
|
|
89
|
+
// A swell travelling right to left, in eighth-blocks. Two summed sines at different
|
|
90
|
+
// wavelengths — one sine on its own reads as a metronome. Two-tone rather than a
|
|
91
|
+
// per-cell gradient, so a frame is a handful of escapes and not one per column.
|
|
92
|
+
function wave(phase) {
|
|
93
|
+
let out = "", tone = "";
|
|
94
|
+
for (let x = 0; x < w.width; x++) {
|
|
95
|
+
const y = (Math.sin(x * 0.45 - phase) * 0.65 + Math.sin(x * 0.21 - phase * 0.6) * 0.35 + 1) / 2;
|
|
96
|
+
const i = Math.max(0, Math.min(7, Math.round(y * 7)));
|
|
97
|
+
const want = i >= 4 ? w.crest : w.trough;
|
|
98
|
+
if (want !== tone) { out += want; tone = want; }
|
|
99
|
+
out += w.blocks[i];
|
|
100
|
+
}
|
|
101
|
+
return out + w.off;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function draw() {
|
|
105
|
+
const secs = Math.round((Date.now() - t0) / 1000);
|
|
106
|
+
const msg = w.msgs[Atomics.load(s, PHASE)];
|
|
107
|
+
const age = secs >= 3 ? w.dim + " " + secs + "s" + w.off : "";
|
|
108
|
+
fs.writeSync(2, w.cr + " " + w.anchor + " " + wave(frame++ * 0.35) + " " + w.dim + msg + "…" + w.off + age + w.clearEol);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Atomics.wait doubles as the sleep: an exact 80ms tick that the main thread can cut
|
|
112
|
+
// short the instant it needs the line back.
|
|
113
|
+
Atomics.wait(s, STOP, 0, w.hold);
|
|
114
|
+
if (!Atomics.load(s, STOP)) {
|
|
115
|
+
fs.writeSync(2, w.hideCursor);
|
|
116
|
+
Atomics.store(s, DREW, 1);
|
|
117
|
+
while (!Atomics.load(s, STOP)) {
|
|
118
|
+
draw();
|
|
119
|
+
Atomics.wait(s, STOP, 0, 80);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
Atomics.store(s, ACK, 1);
|
|
123
|
+
Atomics.notify(s, ACK);
|
|
124
|
+
`,
|
|
125
|
+
{
|
|
126
|
+
eval: true,
|
|
127
|
+
stdout: false,
|
|
128
|
+
workerData: {
|
|
129
|
+
sab,
|
|
130
|
+
width,
|
|
131
|
+
hold: HOLD_MS,
|
|
132
|
+
blocks: "▁▂▃▄▅▆▇█",
|
|
133
|
+
msgs: ["hoisting sail", "raising the colours"],
|
|
134
|
+
anchor: "\x1b[38;5;69m⚓\x1b[0m",
|
|
135
|
+
crest: "\x1b[38;5;109m",
|
|
136
|
+
trough: "\x1b[38;5;67m",
|
|
137
|
+
dim: "\x1b[2m",
|
|
138
|
+
off: "\x1b[0m",
|
|
139
|
+
cr: "\r",
|
|
140
|
+
clearEol: "\x1b[K",
|
|
141
|
+
hideCursor: "\x1b[?25l",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
worker.unref(); // never the reason this process stays alive
|
|
146
|
+
worker.on("error", () => Atomics.store(state, STOP, 1)); // a splash is never worth a crash
|
|
147
|
+
|
|
148
|
+
let running = true;
|
|
149
|
+
let started = false; // seen TUI.start()
|
|
150
|
+
let bytesAfterStart = 0;
|
|
151
|
+
let appHidCursor = false; // Pi hid the cursor — leave it hidden on the way out
|
|
152
|
+
|
|
153
|
+
// Park the drawing thread and WAIT for it to confirm, so the caller can write to the
|
|
154
|
+
// terminal knowing nothing else will. Without the acknowledgement the worker could
|
|
155
|
+
// land one last frame on top of Pi's first paint.
|
|
156
|
+
function park() {
|
|
157
|
+
Atomics.store(state, STOP, 1);
|
|
158
|
+
Atomics.notify(state, STOP);
|
|
159
|
+
if (!Atomics.load(state, ACK)) Atomics.wait(state, ACK, 0, 50);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function clearLine() {
|
|
163
|
+
if (Atomics.load(state, DREW)) errWrite("\r\x1b[K");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stop() {
|
|
167
|
+
if (!running) return;
|
|
168
|
+
running = false;
|
|
169
|
+
park();
|
|
170
|
+
clearLine();
|
|
171
|
+
// Only give the cursor back if Pi hasn't deliberately hidden it — the TUI hides it
|
|
172
|
+
// for the whole session and would never get the chance to hide it again.
|
|
173
|
+
if (Atomics.load(state, DREW) && !appHidCursor) errWrite("\x1b[?25h");
|
|
174
|
+
process.stdout.write = outWrite;
|
|
175
|
+
err.write = errWrite;
|
|
176
|
+
worker.terminate();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── watch Pi's output ─────────────────────────────────────────────────────
|
|
180
|
+
const size = (chunk) =>
|
|
181
|
+
typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk?.length ?? 0;
|
|
182
|
+
const text = (chunk) =>
|
|
183
|
+
typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("latin1") : "";
|
|
184
|
+
|
|
185
|
+
process.stdout.write = function (chunk, ...rest) {
|
|
186
|
+
if (running) {
|
|
187
|
+
const s = text(chunk);
|
|
188
|
+
if (s.includes("\x1b[?25l")) appHidCursor = true;
|
|
189
|
+
if (started) {
|
|
190
|
+
bytesAfterStart += size(chunk);
|
|
191
|
+
// Erase our line BEFORE the write lands, so Pi's output — a log line now, the
|
|
192
|
+
// first frame in a moment — never has half a wave in front of it.
|
|
193
|
+
if (bytesAfterStart >= FRAME_BYTES) stop();
|
|
194
|
+
else clearLine();
|
|
195
|
+
} else if (s.includes("\x1b[?2004h")) {
|
|
196
|
+
started = true;
|
|
197
|
+
Atomics.store(state, PHASE, 1);
|
|
198
|
+
clearLine();
|
|
199
|
+
} else {
|
|
200
|
+
clearLine();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return outWrite(chunk, ...rest);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Pi's own warnings go to stderr, on the line we're animating.
|
|
207
|
+
err.write = function (chunk, ...rest) {
|
|
208
|
+
if (running) clearLine();
|
|
209
|
+
return errWrite(chunk, ...rest);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
process.on("exit", stop);
|
|
213
|
+
|
|
214
|
+
// ── setRawMode EIO ────────────────────────────────────────────────────────
|
|
215
|
+
// The other half of the long silent boot. Pi grabs raw mode ~19s in, and the tcsetattr
|
|
216
|
+
// behind it returns EIO when the terminal is no longer ours to configure — an orphaned
|
|
217
|
+
// process group (the shell that started us has exited), or a controlling terminal that
|
|
218
|
+
// was revoked outright (window or tab closed, ssh dropped, session torn down). A boot
|
|
219
|
+
// that spends half a minute unattended is exactly when that happens. Pi lets the error
|
|
220
|
+
// out as an uncaught exception, so the user's reward for waiting is a native stack
|
|
221
|
+
// trace ending in node:tty. Nothing can rescue the TUI — it has no way to read keys —
|
|
222
|
+
// but it can say what happened in a sentence. (A merely BACKGROUNDED process is a
|
|
223
|
+
// different case: it gets SIGTTOU and stops, and `fg` resumes it as normal.)
|
|
224
|
+
const setRawMode = process.stdin.setRawMode?.bind(process.stdin);
|
|
225
|
+
if (setRawMode) {
|
|
226
|
+
process.stdin.setRawMode = function (mode) {
|
|
227
|
+
try {
|
|
228
|
+
return setRawMode(mode);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e?.code !== "EIO") throw e;
|
|
231
|
+
stop();
|
|
232
|
+
errWrite(
|
|
233
|
+
[
|
|
234
|
+
"",
|
|
235
|
+
" ⚓ Privateer couldn't take the helm — this terminal stopped accepting keyboard",
|
|
236
|
+
" control while the agent was still loading (setRawMode EIO).",
|
|
237
|
+
"",
|
|
238
|
+
" That usually means the window, tab or ssh session it started in went away.",
|
|
239
|
+
` Run \x1b[1m${process.env.PRIVATEER_CMD || "privateer"}\x1b[0m again from a terminal you're sitting in front of.`,
|
|
240
|
+
"",
|
|
241
|
+
].join("\n") + "\n",
|
|
242
|
+
);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Types for `privateer verify`. The implementation is plain .mjs for the same
|
|
2
|
+
// reason apply-patches.mjs is: bin/ runs under a bare `node`, before any transpiler.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check the install rooted at `repo` and print a report. Returns a process exit
|
|
6
|
+
* code: 0 when nothing FAILED (inconclusive checks are not failures), 1 otherwise.
|
|
7
|
+
*
|
|
8
|
+
* `offline` skips every check that needs the npm registry.
|
|
9
|
+
*/
|
|
10
|
+
export function verify(opts: { repo: string; offline?: boolean }): Promise<number>;
|