@timqi/pier 0.0.8 → 0.0.15
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 +26 -9
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +279 -32
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/conversations.js +10 -0
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +99 -11
- package/dist/db.js +87 -0
- package/dist/extensions/index.js +37 -0
- package/dist/extensions/web/anthropic.js +118 -0
- package/dist/extensions/web/artifacts.js +62 -0
- package/dist/extensions/web/content.js +130 -0
- package/dist/extensions/web/http.js +106 -0
- package/dist/extensions/web/index.js +9 -0
- package/dist/extensions/web/json.js +5 -0
- package/dist/extensions/web/language.js +47 -0
- package/dist/extensions/web/openai.js +112 -0
- package/dist/extensions/web/provider.js +121 -0
- package/dist/extensions/web/tools.js +304 -0
- package/dist/limits.js +14 -0
- package/dist/main.js +76 -10
- package/dist/paths.js +21 -1
- package/dist/settings.js +112 -13
- package/dist/tasks/agent.js +18 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/definitions.js +56 -12
- package/dist/tasks/execution.js +5 -1
- package/dist/tasks/groups.js +4 -4
- package/dist/tasks/messages.js +4 -2
- package/dist/tasks/runs.js +2 -2
- package/dist/tasks/service.js +16 -6
- package/dist/tasks/tool.js +0 -12
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +175 -22
- package/dist/web/providers.js +16 -0
- package/dist/web/public/assets/{ghostty-web-CcIc8O2I.js → ghostty-web-xcUrfRRs.js} +1 -1
- package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
- package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +33 -9
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +170 -52
- package/dist/web/session-state.js +57 -44
- package/dist/web/terminal.js +34 -4
- package/dist/web/types.js +5 -0
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DmDJKOLH.js +0 -90
- package/dist/web/public/assets/index-gcSJ9QZ5.css +0 -2
package/dist/tools.js
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
// The binaries Pier manages for itself: which ones exist, how they stay
|
|
2
|
+
// current, and the one directory they sit on ahead of the machine's own.
|
|
3
|
+
//
|
|
4
|
+
// Pier writes no downloader. `ubix` (github:timqi/ubix) is a declarative
|
|
5
|
+
// installer that already knows how to find the right release asset for a
|
|
6
|
+
// platform, so the only thing fetched here is ubix itself; everything after is
|
|
7
|
+
// a generated config file plus `ubix upgrade --all`. The tool-specific part is
|
|
8
|
+
// data (MANAGED below) — the next tool is a table row, not a code path.
|
|
9
|
+
//
|
|
10
|
+
// Instance-layer, and nothing above it: node stdlib, paths.ts, log.ts, db.ts
|
|
11
|
+
// (the sync lock is a row, not a file) and `core/types.ts` type-only, for the
|
|
12
|
+
// one shape the Console draws a switch from. It knows nothing about tasks,
|
|
13
|
+
// sessions or the web — tools-task.ts owns the task that calls it, because a
|
|
14
|
+
// module that scheduled itself would be two modules.
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { delimiter, join } from "node:path";
|
|
19
|
+
import { pierDb } from "./db.js";
|
|
20
|
+
import { logger } from "./log.js";
|
|
21
|
+
import { pierPath, resolveAgentDir } from "./paths.js";
|
|
22
|
+
const log = logger("tools");
|
|
23
|
+
/** Where the ubix build Pier bootstraps comes from. The API, not a fixed
|
|
24
|
+
* download URL: the asset name carries the release tag, so the tag has to be
|
|
25
|
+
* asked for before anything can be fetched. */
|
|
26
|
+
const UBIX_LATEST = "https://api.github.com/repos/timqi/ubix/releases/latest";
|
|
27
|
+
/** The catalog. Data, not code: a new tool is a row, not a branch anywhere in
|
|
28
|
+
* this file — only `rtk` has provisioning, and only because it registers
|
|
29
|
+
* something with Pi. */
|
|
30
|
+
export const MANAGED = [
|
|
31
|
+
{
|
|
32
|
+
kind: "extension",
|
|
33
|
+
name: "rtk",
|
|
34
|
+
toml: `spec = "github:rtk-ai/rtk"`,
|
|
35
|
+
summary: "Compresses long bash output before it reaches the model. Ships as a " +
|
|
36
|
+
"command, and installs its own Pi extension into Pier's agent dir — " +
|
|
37
|
+
"refreshed on every update.",
|
|
38
|
+
// Write-if-changed inside rtk, so re-running it after an upgrade is the
|
|
39
|
+
// extension-update path and costs nothing when nothing moved.
|
|
40
|
+
provision: ["init", "-g", "--agent", "pi"],
|
|
41
|
+
deprovision: ["init", "--uninstall", "--agent", "pi", "--global"],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
kind: "tool",
|
|
45
|
+
name: "rg",
|
|
46
|
+
// `exe`, because ubi looks for files named after the *project* and
|
|
47
|
+
// ripgrep ships `rg`: without it the install fails with "could not find
|
|
48
|
+
// any files matching [ripgrep*]". Found by installing it for real.
|
|
49
|
+
toml: `spec = "github:BurntSushi/ripgrep"\nexe = "rg"`,
|
|
50
|
+
summary: "ripgrep: searches a tree by content, fast enough to be the default.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
kind: "tool",
|
|
54
|
+
name: "fd",
|
|
55
|
+
toml: `spec = "github:sharkdp/fd"`,
|
|
56
|
+
summary: "Finds files by name, respecting .gitignore — what `find` should feel like.",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
kind: "tool",
|
|
60
|
+
name: "wt",
|
|
61
|
+
toml: `spec = "github:max-sixty/worktrunk"\nexe = "wt"`,
|
|
62
|
+
summary: "worktrunk: git worktrees as one command — branch, switch, merge, clean up.",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
kind: "tool",
|
|
66
|
+
name: "jq",
|
|
67
|
+
// No `exe` and no `rename`: jq publishes bare per-platform binaries
|
|
68
|
+
// (`jq-linux-amd64`, not an archive), and ubi installs one of those under
|
|
69
|
+
// the tool's own name — observed landing as `bin/jq`, with `jq --version`
|
|
70
|
+
// answering jq-1.8.2. Archive-versus-binary is exactly what bit rg and wt,
|
|
71
|
+
// so what was seen is written down rather than assumed.
|
|
72
|
+
toml: `spec = "github:jqlang/jq"`,
|
|
73
|
+
summary: "Slices, filters and reshapes JSON on the command line.",
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
/** A binary's name on disk, so what goes on the PATH is predictable. No dot:
|
|
77
|
+
* `[tools.a.b]` is a different table than the one Pier means to write. */
|
|
78
|
+
const TOOL_NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/i;
|
|
79
|
+
/** Every one is a binary on every PATH; a list this long is already a smell. */
|
|
80
|
+
const MAX_CUSTOM = 16;
|
|
81
|
+
/** A tool's block is a handful of keys. Past this it is a config file, and a
|
|
82
|
+
* textarea in a settings pane is the wrong place to keep one. Generous on
|
|
83
|
+
* purpose: a templated `url:` tool carries two ~200-character URLs. */
|
|
84
|
+
const MAX_BODY = 2000;
|
|
85
|
+
/** The `spec = "…"` a block must carry, for the boundary check and for the
|
|
86
|
+
* one line the Console shows about a tool it has not installed yet. */
|
|
87
|
+
export function specOf(toml) {
|
|
88
|
+
// Multiline strings are skipped, not scanned: a `spec = "…"` inside one is
|
|
89
|
+
// text, not a key, and reading it as the spec would let a block with no real
|
|
90
|
+
// spec past the boundary check below.
|
|
91
|
+
let inside = false;
|
|
92
|
+
for (const line of toml.split("\n")) {
|
|
93
|
+
const fences = (line.match(/"""|'''/g) ?? []).length;
|
|
94
|
+
if (inside || fences % 2 === 1) {
|
|
95
|
+
inside = inside ? fences % 2 === 0 : true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const match = /^\s*spec\s*=\s*"([^"]+)"\s*(?:#.*)?$|^\s*spec\s*=\s*'([^']+)'\s*(?:#.*)?$/.exec(line);
|
|
99
|
+
const value = (match?.[1] ?? match?.[2])?.trim();
|
|
100
|
+
if (value)
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/** What a custom entry must be, said once so both the route and the stored
|
|
106
|
+
* row are checked by the same rule. */
|
|
107
|
+
export const CUSTOM_TOOL_RULES = `each custom tool needs a name (letters, digits, _ and -, ≤32 characters, not a built-in or "ubix")` +
|
|
108
|
+
` and a block body with a spec line — spec = "github:owner/repo", plus any ubix keys it needs.` +
|
|
109
|
+
` Pier writes the [tools.<name>] header itself: a line opening a section of its own is refused, so are` +
|
|
110
|
+
` control characters and a body over ${String(MAX_BODY)} characters; at most ${String(MAX_CUSTOM)} tools`;
|
|
111
|
+
/**
|
|
112
|
+
* Boundary check, rejecting rather than repairing.
|
|
113
|
+
*
|
|
114
|
+
* The body is the operator's — which keys ubix's ToolConfig takes is ubix's
|
|
115
|
+
* vocabulary, and a wrong type is ubix's error to report, in the run output
|
|
116
|
+
* where it already lands. What Pier guards is the *structure* of the file it
|
|
117
|
+
* generates: nothing may open a section, because a body that could write
|
|
118
|
+
* `[settings]` could point `install_dir` anywhere, and one that could write
|
|
119
|
+
* `[tools.rg]` could redefine a tool the operator never touched. A name Pier
|
|
120
|
+
* already manages is refused for the same reason — two rows installing into
|
|
121
|
+
* one filename is a switch whose meaning depends on which ran last.
|
|
122
|
+
*
|
|
123
|
+
* `{name, spec}` from an older Pier is read as the body it stood for: those
|
|
124
|
+
* rows were written by this code, and orphaning them would silently drop a
|
|
125
|
+
* tool the operator is still using.
|
|
126
|
+
*/
|
|
127
|
+
export function normalizeCustomTools(raw,
|
|
128
|
+
/** Names this instance already answers to that this file cannot see — the
|
|
129
|
+
* bundled extensions live behind the Pi SDK, so main.ts hands them in. */
|
|
130
|
+
reserved = []) {
|
|
131
|
+
// Case-insensitively: two names differing only in case are one filename on a
|
|
132
|
+
// case-insensitive filesystem, and one switch whose meaning depends on which
|
|
133
|
+
// ran last.
|
|
134
|
+
const taken = new Set([...MANAGED.map((tool) => tool.name), ...reserved, "ubix"].map((name) => name.toLowerCase()));
|
|
135
|
+
if (!Array.isArray(raw) || raw.length > MAX_CUSTOM)
|
|
136
|
+
return null;
|
|
137
|
+
const tools = [];
|
|
138
|
+
for (const item of raw) {
|
|
139
|
+
const given = record(item);
|
|
140
|
+
if (!given)
|
|
141
|
+
return null;
|
|
142
|
+
const { name, toml, spec } = given;
|
|
143
|
+
if (typeof name !== "string")
|
|
144
|
+
return null;
|
|
145
|
+
const cleanName = name.trim();
|
|
146
|
+
if (!TOOL_NAME.test(cleanName))
|
|
147
|
+
return null;
|
|
148
|
+
if (taken.has(cleanName.toLowerCase()))
|
|
149
|
+
return null;
|
|
150
|
+
if (tools.some((tool) => tool.name.toLowerCase() === cleanName.toLowerCase()))
|
|
151
|
+
return null;
|
|
152
|
+
// The migration: a stored `{name, spec}` is the block it always meant.
|
|
153
|
+
const body = typeof toml === "string"
|
|
154
|
+
? toml.trim()
|
|
155
|
+
: typeof spec === "string" && spec.trim()
|
|
156
|
+
? `spec = ${tomlString(spec.trim())}`
|
|
157
|
+
: null;
|
|
158
|
+
if (body === null || !body || body.length > MAX_BODY)
|
|
159
|
+
return null;
|
|
160
|
+
// A section header would take the rest of the file with it.
|
|
161
|
+
if (body.split("\n").some((line) => line.trimStart().startsWith("[")))
|
|
162
|
+
return null;
|
|
163
|
+
// Tabs and newlines are the only control characters a TOML body needs.
|
|
164
|
+
// Character by character rather than by regex class, so the rule reads as
|
|
165
|
+
// what it is and no linter has to guess whether the escapes were meant.
|
|
166
|
+
if ([...body].some((ch) => (ch < " " && ch !== "\n" && ch !== "\t") || ch === "\u007f"))
|
|
167
|
+
return null;
|
|
168
|
+
if (!specOf(body))
|
|
169
|
+
return null;
|
|
170
|
+
tools.push({ name: cleanName, toml: body });
|
|
171
|
+
}
|
|
172
|
+
return tools;
|
|
173
|
+
}
|
|
174
|
+
/** `~/.pier/tools/…` — install target, generated ubix config, ubix state. */
|
|
175
|
+
export const toolsDir = (...parts) => pierPath("tools", ...parts);
|
|
176
|
+
/** The one directory that goes on PATH: ubix installs into it, and everything
|
|
177
|
+
* Pier spawns inherits it. */
|
|
178
|
+
export const toolsBin = () => toolsDir("bin");
|
|
179
|
+
/**
|
|
180
|
+
* First on PATH, once, at boot. First rather than last on purpose: a tool
|
|
181
|
+
* switched on in the Console is Pier's copy at Pier's version, whatever the
|
|
182
|
+
* machine happens to have in /usr/bin.
|
|
183
|
+
*/
|
|
184
|
+
export function prependPath(env = process.env, bin = toolsBin()) {
|
|
185
|
+
const current = env.PATH ?? "";
|
|
186
|
+
if (current.split(delimiter).includes(bin))
|
|
187
|
+
return;
|
|
188
|
+
mkdirSync(bin, { recursive: true }); // a PATH entry that does not exist is a shell's problem
|
|
189
|
+
env.PATH = current ? `${bin}${delimiter}${current}` : bin;
|
|
190
|
+
}
|
|
191
|
+
/** `stale` is heartbeat age, never how long the work has taken; `wait` is what
|
|
192
|
+
* a waiter gives a live holder before giving up with a reason. */
|
|
193
|
+
const LOCK_TIMING = { heartbeatMs: 5_000, staleMs: 30_000, waitMs: 20 * 60_000, pollMs: 200 };
|
|
194
|
+
/**
|
|
195
|
+
* One tools sync at a time on this machine, whichever process asked.
|
|
196
|
+
*
|
|
197
|
+
* The contract:
|
|
198
|
+
* - *Ownership* is one row and a random token. Every write to that row —
|
|
199
|
+
* release, takeover, refresh — matches on the token, so no party can undo
|
|
200
|
+
* another's.
|
|
201
|
+
* - *Staleness* is heartbeat age. A holder that stops beating can be taken
|
|
202
|
+
* over; how long its work has been running never enters into it.
|
|
203
|
+
* - *A heartbeat cannot prove a holder is dead*, so a holder does not assume
|
|
204
|
+
* it is still the holder: it passes a fence before every step that changes
|
|
205
|
+
* anything, and a sync that was taken over fails saying so rather than
|
|
206
|
+
* writing beside its successor.
|
|
207
|
+
* - No transaction is held for the length of a sync: acquire, refresh, fence
|
|
208
|
+
* and release are each their own.
|
|
209
|
+
*
|
|
210
|
+
* What the fence does *not* guarantee, deliberately. It bounds the overlap to
|
|
211
|
+
* one already-started step — a holder stopped between its fence and that
|
|
212
|
+
* step's own writes, or an `execFile` child that outlives its stopped parent,
|
|
213
|
+
* still finishes that step. Closing it would take a kernel lock every child
|
|
214
|
+
* inherits, which is a native dependency (AGENTS.md 8) or `flock(1)`, which
|
|
215
|
+
* macOS does not ship. It is not paid for, because the floor underneath is
|
|
216
|
+
* already a kernel lock: ubix takes an exclusive advisory flock on its own
|
|
217
|
+
* state file and Pier passes `--wait`, so two overlapping syncs cannot corrupt
|
|
218
|
+
* what ubix records — the worst case is a redundant install, or a config.toml
|
|
219
|
+
* written from a stale settings snapshot, which the next sync converges.
|
|
220
|
+
*/
|
|
221
|
+
export class SyncLock {
|
|
222
|
+
#db;
|
|
223
|
+
#timing;
|
|
224
|
+
constructor(db, timing = {}) {
|
|
225
|
+
this.#db = db;
|
|
226
|
+
this.#timing = { ...LOCK_TIMING, ...timing };
|
|
227
|
+
}
|
|
228
|
+
/** Run `work` with the lock held, waiting for whoever has it. `work` is
|
|
229
|
+
* handed the fence and must call it before every step that changes
|
|
230
|
+
* anything outside this process. */
|
|
231
|
+
async run(work) {
|
|
232
|
+
const token = randomUUID();
|
|
233
|
+
const deadline = Date.now() + this.#timing.waitMs;
|
|
234
|
+
while (!this.#acquire(token)) {
|
|
235
|
+
if (Date.now() > deadline) {
|
|
236
|
+
throw new Error(`another tools sync has held the lock for ${String(Math.round(this.#timing.waitMs / 60_000))}` +
|
|
237
|
+
` minutes — nothing was changed`);
|
|
238
|
+
}
|
|
239
|
+
await new Promise((resolve) => setTimeout(resolve, this.#timing.pollMs));
|
|
240
|
+
}
|
|
241
|
+
// Unref'd: a beating heart is not a reason for the process to stay up.
|
|
242
|
+
const beat = setInterval(() => this.#refresh(token), this.#timing.heartbeatMs);
|
|
243
|
+
beat.unref();
|
|
244
|
+
try {
|
|
245
|
+
return await work(() => this.#fence(token));
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
clearInterval(beat);
|
|
249
|
+
this.#db.prepare("DELETE FROM tools_sync_lock WHERE token = ?").run(token);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Take the lock, or take it over from a holder that stopped beating — both
|
|
253
|
+
* in one immediate transaction, so two waiters cannot both win. */
|
|
254
|
+
#acquire(token) {
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
257
|
+
try {
|
|
258
|
+
const stale = this.#db.prepare("DELETE FROM tools_sync_lock WHERE heartbeat_at <= ?")
|
|
259
|
+
.run(now - this.#timing.staleMs);
|
|
260
|
+
const taken = this.#db.prepare("INSERT OR IGNORE INTO tools_sync_lock (id, token, heartbeat_at) VALUES (1, ?, ?)")
|
|
261
|
+
.run(token, now);
|
|
262
|
+
this.#db.exec("COMMIT");
|
|
263
|
+
if (stale.changes && taken.changes)
|
|
264
|
+
log.warn("took over a tools sync lock whose holder stopped beating");
|
|
265
|
+
return taken.changes === 1;
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
this.#db.exec("ROLLBACK");
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Still ours? The authority is the row, asked now — not the heartbeat's own
|
|
273
|
+
* bookkeeping, which a stopped process does not get to run either. */
|
|
274
|
+
#fence(token) {
|
|
275
|
+
const row = this.#db.prepare("SELECT token FROM tools_sync_lock").get();
|
|
276
|
+
if (row?.token !== token) {
|
|
277
|
+
throw new Error("this sync lost its lock — another tools sync took it over while this one was stopped, so it went no further");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Say we are alive. A refresh that changes nothing is the first sign of a
|
|
281
|
+
* takeover; the fence is what acts on it, at the next step. */
|
|
282
|
+
#refresh(token) {
|
|
283
|
+
const beat = this.#db.prepare("UPDATE tools_sync_lock SET heartbeat_at = ? WHERE token = ?").run(Date.now(), token);
|
|
284
|
+
if (!beat.changes)
|
|
285
|
+
log.warn("this tools sync no longer holds the lock — it stops at its next step");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Converge, don't race.
|
|
290
|
+
*
|
|
291
|
+
* Every switch is its own request and every request wants the *current* set
|
|
292
|
+
* installed, but the task layer refuses an overlapping run (`skipped`). Three
|
|
293
|
+
* switches flipped in one second therefore produced one run that had read the
|
|
294
|
+
* set as it stood halfway through and two runs that did nothing at all — the
|
|
295
|
+
* Console showed four tools on and the machine had two, with nothing anywhere
|
|
296
|
+
* saying so.
|
|
297
|
+
*
|
|
298
|
+
* So a request that lands on a running sync is *remembered*, not queued: one
|
|
299
|
+
* bit, so a click storm cannot grow a backlog, and the moment the run settles
|
|
300
|
+
* exactly one more run goes — reading the set as it is by then. That run can
|
|
301
|
+
* be overlapped in turn and the bit set again; it terminates because every
|
|
302
|
+
* follow-up starts strictly after the request that asked for it.
|
|
303
|
+
*/
|
|
304
|
+
export function coalescedSync(
|
|
305
|
+
/** Start one run now, and say what to wait for. Structural, so this file
|
|
306
|
+
* still knows nothing about tasks/. */
|
|
307
|
+
run, onFailure) {
|
|
308
|
+
let chain = null;
|
|
309
|
+
let pending = false;
|
|
310
|
+
const drive = async () => {
|
|
311
|
+
do {
|
|
312
|
+
// Cleared before the run, not after: a request arriving while this one is
|
|
313
|
+
// in flight must set it again and earn its own follow-up.
|
|
314
|
+
pending = false;
|
|
315
|
+
const { ran, settled } = run();
|
|
316
|
+
await settled;
|
|
317
|
+
// Refused as an overlap: nothing of ours has run yet, so go again once
|
|
318
|
+
// whatever was in flight is done.
|
|
319
|
+
if (ran === "overlapped")
|
|
320
|
+
pending = true;
|
|
321
|
+
} while (pending);
|
|
322
|
+
};
|
|
323
|
+
return () => {
|
|
324
|
+
if (chain) {
|
|
325
|
+
pending = true;
|
|
326
|
+
return "waiting";
|
|
327
|
+
}
|
|
328
|
+
let finish;
|
|
329
|
+
// Assigned before `drive` is called: a drive that never awaits would
|
|
330
|
+
// otherwise finish before this variable existed, and every later request
|
|
331
|
+
// would wait forever on a chain nobody is driving.
|
|
332
|
+
chain = new Promise((resolve) => (finish = resolve));
|
|
333
|
+
void drive().catch(onFailure).finally(() => {
|
|
334
|
+
chain = null;
|
|
335
|
+
pending = false;
|
|
336
|
+
finish();
|
|
337
|
+
});
|
|
338
|
+
return "started";
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const spawnExec = (file, args, env) => new Promise((resolve) => {
|
|
342
|
+
execFile(file, [...args], { env, maxBuffer: 8 * 1024 * 1024, timeout: 15 * 60_000 }, (err, stdout, stderr) => {
|
|
343
|
+
const failure = err;
|
|
344
|
+
const code = typeof failure?.code === "number" ? failure.code : failure ? null : 0;
|
|
345
|
+
resolve({
|
|
346
|
+
code,
|
|
347
|
+
stdout,
|
|
348
|
+
// A spawn that never happened (ENOENT, EACCES) writes nothing to
|
|
349
|
+
// stderr, and "exited null" alone would say nothing about why.
|
|
350
|
+
stderr: failure && code === null ? `${stderr}${failure.message}` : stderr,
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
355
|
+
? value
|
|
356
|
+
: null;
|
|
357
|
+
/** The `--json` document shape Pier was written against. ubix bumps this on
|
|
358
|
+
* any breaking change to the fields read below. */
|
|
359
|
+
const UBIX_SCHEMA = 1;
|
|
360
|
+
/**
|
|
361
|
+
* Every byte of ubix JSON Pier ever reads, parsed and validated here and
|
|
362
|
+
* nowhere else — one function, so a field ubix renames is a one-function fix
|
|
363
|
+
* rather than a hunt through the callers.
|
|
364
|
+
*
|
|
365
|
+
* Both documents are `{schema_version, tools: [...]}`; the entries differ, so
|
|
366
|
+
* one shape carries both and the fields the other command does not send stay
|
|
367
|
+
* null.
|
|
368
|
+
*
|
|
369
|
+
* Everything that is not exactly what it should be throws, including a schema
|
|
370
|
+
* version this does not know. The alternative was tried and is worse: a field
|
|
371
|
+
* that fails to parse would become `null`, an installed tool would be drawn as
|
|
372
|
+
* absent, and the switches and the machine would disagree with nothing saying
|
|
373
|
+
* so (§5b). A caller that cannot read ubix reports that it cannot; it never
|
|
374
|
+
* reports "no tools".
|
|
375
|
+
*/
|
|
376
|
+
export function parseUbixJson(stdout) {
|
|
377
|
+
let doc;
|
|
378
|
+
try {
|
|
379
|
+
doc = JSON.parse(stdout.trim());
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
throw new Error(`ubix --json did not answer with JSON: ${stdout.trim().slice(0, 200) || "(nothing)"}`);
|
|
383
|
+
}
|
|
384
|
+
const top = record(doc);
|
|
385
|
+
if (!top || !Array.isArray(top.tools)) {
|
|
386
|
+
throw new Error(`ubix --json has no tools array: ${stdout.trim().slice(0, 200)}`);
|
|
387
|
+
}
|
|
388
|
+
if (top.schema_version !== UBIX_SCHEMA) {
|
|
389
|
+
throw new Error(`ubix --json is schema ${JSON.stringify(top.schema_version)}, and Pier reads ${String(UBIX_SCHEMA)}` +
|
|
390
|
+
` — refusing to guess what its fields mean now`);
|
|
391
|
+
}
|
|
392
|
+
return top.tools.map((value) => {
|
|
393
|
+
const entry = record(value);
|
|
394
|
+
if (!entry)
|
|
395
|
+
throw new Error(`ubix --json entry is not an object: ${JSON.stringify(value).slice(0, 120)}`);
|
|
396
|
+
const where = typeof entry.name === "string" ? entry.name : JSON.stringify(value).slice(0, 80);
|
|
397
|
+
/** A string, an explicit null, or absent. Anything else is a field that
|
|
398
|
+
* moved, and guessing `null` for it is how "installed" becomes "gone". */
|
|
399
|
+
const str = (key) => {
|
|
400
|
+
const raw = entry[key];
|
|
401
|
+
if (raw === undefined || raw === null)
|
|
402
|
+
return null;
|
|
403
|
+
if (typeof raw !== "string")
|
|
404
|
+
throw new Error(`ubix --json: ${where}.${key} is not a string`);
|
|
405
|
+
return raw.trim() || null;
|
|
406
|
+
};
|
|
407
|
+
const flag = (key) => {
|
|
408
|
+
const raw = entry[key];
|
|
409
|
+
if (raw === undefined || raw === null)
|
|
410
|
+
return null;
|
|
411
|
+
if (typeof raw !== "boolean")
|
|
412
|
+
throw new Error(`ubix --json: ${where}.${key} is not a boolean`);
|
|
413
|
+
return raw;
|
|
414
|
+
};
|
|
415
|
+
const list = (key) => {
|
|
416
|
+
const raw = entry[key];
|
|
417
|
+
if (raw === undefined || raw === null)
|
|
418
|
+
return [];
|
|
419
|
+
if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string")) {
|
|
420
|
+
throw new Error(`ubix --json: ${where}.${key} is not a list of paths`);
|
|
421
|
+
}
|
|
422
|
+
return raw;
|
|
423
|
+
};
|
|
424
|
+
const name = str("name");
|
|
425
|
+
if (!name)
|
|
426
|
+
throw new Error(`ubix --json entry has no name: ${JSON.stringify(value).slice(0, 120)}`);
|
|
427
|
+
const to = str("to_version");
|
|
428
|
+
const action = str("action");
|
|
429
|
+
return {
|
|
430
|
+
name,
|
|
431
|
+
version: str("installed_version") ?? to,
|
|
432
|
+
path: list("install_paths")[0] ?? null,
|
|
433
|
+
installed: flag("installed"),
|
|
434
|
+
exists: flag("exists"),
|
|
435
|
+
missingPaths: list("missing_paths"),
|
|
436
|
+
action,
|
|
437
|
+
to,
|
|
438
|
+
// A failure with no words is still a failure: without this it reads as a
|
|
439
|
+
// tool that is fine, which is the one thing this parser may never say.
|
|
440
|
+
error: str("error") ?? (action === "failed" ? "ubix reported it failed and said no more" : null),
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
/** Pier only ever writes two TOML strings itself — the install dir and the
|
|
445
|
+
* spec a legacy `{name, spec}` row is migrated into. A tool's own body is the
|
|
446
|
+
* operator's text and is written verbatim. */
|
|
447
|
+
const tomlString = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
448
|
+
/**
|
|
449
|
+
* The ubix config Pier owns, generated from the enabled set. Never the
|
|
450
|
+
* operator's `~/.config/ubix/config.toml`: Pier rewrites this file on every
|
|
451
|
+
* sync, and doing that to a file a human maintains would delete their tools.
|
|
452
|
+
*/
|
|
453
|
+
export function ubixConfigToml(tools, installDir) {
|
|
454
|
+
const lines = [
|
|
455
|
+
"# Generated by Pier from the tools switched on in the Console.",
|
|
456
|
+
"# Rewritten on every sync — your own ~/.config/ubix/config.toml is untouched.",
|
|
457
|
+
"",
|
|
458
|
+
"[settings]",
|
|
459
|
+
`install_dir = ${tomlString(installDir)}`,
|
|
460
|
+
];
|
|
461
|
+
// Pier owns the headers; the body under each one is written exactly as it
|
|
462
|
+
// was given. A `{version}` placeholder or a 200-character URL is the
|
|
463
|
+
// operator's business and must survive the round trip untouched.
|
|
464
|
+
for (const tool of tools)
|
|
465
|
+
lines.push("", `[tools.${tool.name}]`, tool.toml.trim());
|
|
466
|
+
return `${lines.join("\n")}\n`;
|
|
467
|
+
}
|
|
468
|
+
/** ubix's own words for "I do not have that flag" — clap's message for an
|
|
469
|
+
* unknown argument, and ubix's own refusal on a command that takes no JSON.
|
|
470
|
+
* Nothing else counts as too old. */
|
|
471
|
+
const refusesJson = (stderr) => /unexpected argument\s+'?--json|unrecognized (?:option|argument)\s+'?--json|`--json` is not supported/i
|
|
472
|
+
.test(stderr);
|
|
473
|
+
/** The ubix in `bin/` is older than the `--json` Pier reads. Pier put it
|
|
474
|
+
* there, so this is Pier's to fix (`sync` re-bootstraps), not an errand for
|
|
475
|
+
* the operator. */
|
|
476
|
+
class UbixTooOld extends Error {
|
|
477
|
+
}
|
|
478
|
+
/** The rows an enabled set names: Pier's catalog plus the operator's blocks.
|
|
479
|
+
* A name in neither is not an error — the setting is shape-only, so a row a
|
|
480
|
+
* future release drops must not stop the sync of everything else. */
|
|
481
|
+
function rows(custom) {
|
|
482
|
+
return [...MANAGED, ...custom.map((tool) => ({ kind: "tool", summary: "", custom: true, ...tool }))];
|
|
483
|
+
}
|
|
484
|
+
/** Which release asset is this machine's. Pure, because the mapping is the
|
|
485
|
+
* part worth a test and the download around it is not. */
|
|
486
|
+
export function ubixAsset(tag, platform, arch) {
|
|
487
|
+
const os = platform === "linux" || platform === "darwin" ? platform : null;
|
|
488
|
+
const cpu = arch === "x64" ? "amd64" : arch === "arm64" ? "arm64" : null;
|
|
489
|
+
if (!os || !cpu) {
|
|
490
|
+
throw new Error(`ubix ships no build for ${platform}/${arch} — Pier cannot manage tools on this machine`);
|
|
491
|
+
}
|
|
492
|
+
return `ubix-${os}-${cpu}-${tag}.tar.gz`;
|
|
493
|
+
}
|
|
494
|
+
/** The one place a GitHub release document is read. Same contract as the ubix
|
|
495
|
+
* parser: a shape that is not understood is an error, not an empty list. */
|
|
496
|
+
function parseRelease(doc) {
|
|
497
|
+
const value = record(doc);
|
|
498
|
+
const text = (raw) => (typeof raw === "string" && raw.trim() ? raw.trim() : null);
|
|
499
|
+
const tag = text(value?.tag_name);
|
|
500
|
+
const rawAssets = value?.assets;
|
|
501
|
+
if (!tag || !Array.isArray(rawAssets))
|
|
502
|
+
throw new Error("the ubix release feed has no tag or assets");
|
|
503
|
+
const assets = [];
|
|
504
|
+
for (const raw of rawAssets) {
|
|
505
|
+
const asset = record(raw);
|
|
506
|
+
const name = text(asset?.name);
|
|
507
|
+
const url = text(asset?.browser_download_url);
|
|
508
|
+
if (name && url)
|
|
509
|
+
assets.push({ name, url });
|
|
510
|
+
}
|
|
511
|
+
return { tag, assets };
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The managed-tools operation surface. One object rather than free functions
|
|
515
|
+
* so the two seams it stands on — subprocesses and the network — are injected
|
|
516
|
+
* once and can be replaced wholesale in a test.
|
|
517
|
+
*/
|
|
518
|
+
export class ManagedTools {
|
|
519
|
+
#exec;
|
|
520
|
+
#fetch;
|
|
521
|
+
#root;
|
|
522
|
+
#lock;
|
|
523
|
+
#db;
|
|
524
|
+
constructor(options = {}) {
|
|
525
|
+
this.#exec = options.exec ?? spawnExec;
|
|
526
|
+
this.#fetch = options.fetch ?? ((...args) => fetch(...args));
|
|
527
|
+
this.#root = options.root ?? toolsDir();
|
|
528
|
+
// Opened on the first sync, never in the constructor: `status()` and the
|
|
529
|
+
// Console's catalog need no database, and neither does a process that only
|
|
530
|
+
// reads what is installed.
|
|
531
|
+
this.#db = options.db ?? pierDb;
|
|
532
|
+
}
|
|
533
|
+
get bin() {
|
|
534
|
+
return join(this.#root, "bin");
|
|
535
|
+
}
|
|
536
|
+
/** The ubix binary Pier manages, whether or not it is there yet. */
|
|
537
|
+
get ubixPath() {
|
|
538
|
+
return join(this.bin, "ubix");
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Put ubix in `bin/` if it is not there. Latest release → this platform's
|
|
542
|
+
* asset → sha256 against the release's own `checksums.txt` → extract →
|
|
543
|
+
* atomic rename. Any of those failing throws with what failed: a bootstrap
|
|
544
|
+
* that quietly did nothing would show up later as "the tool never installed"
|
|
545
|
+
* with no reason anywhere.
|
|
546
|
+
*/
|
|
547
|
+
async bootstrapUbix(replace = false) {
|
|
548
|
+
if (existsSync(this.ubixPath) && !replace)
|
|
549
|
+
return this.ubixPath;
|
|
550
|
+
const { tag, assets } = parseRelease(JSON.parse(await this.#getText(UBIX_LATEST)));
|
|
551
|
+
const wanted = ubixAsset(tag, process.platform, process.arch);
|
|
552
|
+
const asset = assets.find((a) => a.name === wanted);
|
|
553
|
+
if (!asset) {
|
|
554
|
+
throw new Error(`ubix ${tag} has no ${wanted} — it ships ${assets.map((a) => a.name).join(", ") || "nothing"}`);
|
|
555
|
+
}
|
|
556
|
+
const sums = assets.find((a) => a.name === "checksums.txt");
|
|
557
|
+
if (!sums)
|
|
558
|
+
throw new Error(`ubix ${tag} publishes no checksums.txt — refusing to install an unverified binary`);
|
|
559
|
+
mkdirSync(this.bin, { recursive: true });
|
|
560
|
+
// Under the same root as bin/, so the install below is a rename and not a
|
|
561
|
+
// copy across filesystems — a half-written binary on PATH is worse than
|
|
562
|
+
// none at all.
|
|
563
|
+
const staging = mkdtempSync(join(this.#root, ".bootstrap-"));
|
|
564
|
+
try {
|
|
565
|
+
const [archive, checksums] = await Promise.all([this.#getBytes(asset.url), this.#getText(sums.url)]);
|
|
566
|
+
const expected = expectedSha256(checksums, wanted);
|
|
567
|
+
const actual = createHash("sha256").update(archive).digest("hex");
|
|
568
|
+
if (actual !== expected) {
|
|
569
|
+
throw new Error(`${wanted} checksum mismatch: expected ${expected}, got ${actual}`);
|
|
570
|
+
}
|
|
571
|
+
const tarball = join(staging, wanted);
|
|
572
|
+
writeFileSync(tarball, archive);
|
|
573
|
+
// The system tar, not a dependency: unpacking one .tar.gz does not earn
|
|
574
|
+
// an npm package (AGENTS.md 8).
|
|
575
|
+
const untar = await this.#exec("tar", ["-xzf", tarball, "-C", staging], process.env);
|
|
576
|
+
if (untar.code !== 0)
|
|
577
|
+
throw new Error(failedRun(`tar on ${wanted}`, untar));
|
|
578
|
+
const extracted = join(staging, "ubix");
|
|
579
|
+
if (!existsSync(extracted))
|
|
580
|
+
throw new Error(`${wanted} contains no "ubix" executable`);
|
|
581
|
+
chmodSync(extracted, 0o755);
|
|
582
|
+
renameSync(extracted, this.ubixPath);
|
|
583
|
+
log.info(`installed ubix ${tag} into ${this.bin}`);
|
|
584
|
+
return this.ubixPath;
|
|
585
|
+
}
|
|
586
|
+
finally {
|
|
587
|
+
rmSync(staging, { recursive: true, force: true });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Converge on what is switched on: uninstall what left the set (letting each
|
|
592
|
+
* tool undo its own footprint first), rewrite the config, upgrade everything,
|
|
593
|
+
* then provision. Returns what happened per tool; whole-run failures throw,
|
|
594
|
+
* because there is nothing per-tool to say about them.
|
|
595
|
+
*
|
|
596
|
+
* The set is *read here*, inside the lock, rather than handed in: ubix reads
|
|
597
|
+
* the config file before it takes its own state lock, so a hand-typed `pier
|
|
598
|
+
* tools sync` overlapping the managed run could write its config after the
|
|
599
|
+
* other had written one and before ubix read either — the older snapshot
|
|
600
|
+
* winning, both exiting 0, and nothing anywhere saying the machine is not
|
|
601
|
+
* what the switches say.
|
|
602
|
+
*
|
|
603
|
+
* `fence` is called before every step that changes anything a second sync
|
|
604
|
+
* could also be changing — the config file, ubix's own state, a tool's
|
|
605
|
+
* footprint. Holding the lock is not proof of holding it *still*: a process
|
|
606
|
+
* paused long enough to look dead is taken over and then resumes, and the
|
|
607
|
+
* fence is what stops it — by failing the sync with that sentence rather
|
|
608
|
+
* than letting it write on top of the sync that replaced it. What that
|
|
609
|
+
* leaves open, and why it is left open, is on `SyncLock`.
|
|
610
|
+
*/
|
|
611
|
+
async sync(read) {
|
|
612
|
+
this.#lock ??= new SyncLock(this.#db());
|
|
613
|
+
return this.#lock.run(async (fence) => {
|
|
614
|
+
const { tools, customTools } = read();
|
|
615
|
+
return this.#converge(tools, customTools, fence);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
async #converge(enabled, custom, fence) {
|
|
619
|
+
const all = rows(custom);
|
|
620
|
+
const wanted = all.filter((tool) => enabled.includes(tool.name));
|
|
621
|
+
// Nothing on and nothing installed: no config to write, no ubix to fetch.
|
|
622
|
+
// A first boot must not reach the network to find out it has no work.
|
|
623
|
+
if (!wanted.length && !existsSync(this.ubixPath)) {
|
|
624
|
+
return { entries: [], failed: false, summary: "no tools switched on" };
|
|
625
|
+
}
|
|
626
|
+
fence();
|
|
627
|
+
const ubix = await this.bootstrapUbix();
|
|
628
|
+
const env = this.#env();
|
|
629
|
+
const entries = [];
|
|
630
|
+
// ubix prunes what its config no longer declares (`--prune` below), but it
|
|
631
|
+
// cannot know that rtk has to uninstall its own Pi extension *before* its
|
|
632
|
+
// binary goes — so the listing survives for exactly that: find the tools
|
|
633
|
+
// leaving the set that have something of their own to undo, and let them.
|
|
634
|
+
const kept = [];
|
|
635
|
+
for (const state of await this.#listing(ubix, env)) {
|
|
636
|
+
const leaving = all.find((tool) => tool.name === state.name);
|
|
637
|
+
if (!leaving?.deprovision || wanted.some((tool) => tool.name === state.name))
|
|
638
|
+
continue;
|
|
639
|
+
fence(); // a tool's own uninstall is a change to the machine
|
|
640
|
+
const error = await this.#provision(env, leaving, leaving.deprovision);
|
|
641
|
+
if (error) {
|
|
642
|
+
// Removing it now would orphan what the deprovision failed to remove,
|
|
643
|
+
// with nothing left able to remove it. It stays declared, stays
|
|
644
|
+
// installed, and the next run tries again.
|
|
645
|
+
kept.push(leaving);
|
|
646
|
+
entries.push({ name: leaving.name, action: "kept", version: null, error });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
fence();
|
|
650
|
+
this.#writeConfig([...wanted, ...kept]);
|
|
651
|
+
// `--prune` is the removal: anything in ubix's state that this config no
|
|
652
|
+
// longer declares is uninstalled by ubix, which knows per source how.
|
|
653
|
+
// `--wait` on the state lock: a hand-typed `pier tools sync` overlapping
|
|
654
|
+
// the managed run should converge behind it, not fail on the lock. Bounded
|
|
655
|
+
// by the subprocess timeout, like everything else here.
|
|
656
|
+
fence();
|
|
657
|
+
const states = await this.#states(ubix, env, ["upgrade", "--all", "--prune", "--wait", "--json"]);
|
|
658
|
+
for (const state of states) {
|
|
659
|
+
// One line per tool: a kept one has already said why it stayed.
|
|
660
|
+
if (wanted.some((tool) => tool.name === state.name))
|
|
661
|
+
continue;
|
|
662
|
+
if (entries.some((entry) => entry.name === state.name))
|
|
663
|
+
continue;
|
|
664
|
+
// A tool that left the set: ubix says what it did with it, and a failure
|
|
665
|
+
// to remove is as much a failure as one to install.
|
|
666
|
+
entries.push({ name: state.name, action: state.action ?? "removed", version: null, error: state.error });
|
|
667
|
+
}
|
|
668
|
+
for (const tool of wanted) {
|
|
669
|
+
const state = states.find((s) => s.name === tool.name);
|
|
670
|
+
const entry = {
|
|
671
|
+
name: tool.name,
|
|
672
|
+
action: state?.action ?? "missing",
|
|
673
|
+
version: state?.to ?? state?.version ?? null,
|
|
674
|
+
// A tool ubix never mentioned is not a tool that is fine.
|
|
675
|
+
error: state?.error ?? (state ? null : "ubix reported nothing about it"),
|
|
676
|
+
};
|
|
677
|
+
if (!entry.error && tool.provision) {
|
|
678
|
+
fence();
|
|
679
|
+
entry.error = await this.#provision(env, tool, tool.provision);
|
|
680
|
+
}
|
|
681
|
+
entries.push(entry);
|
|
682
|
+
}
|
|
683
|
+
const failed = entries.some((entry) => entry.error !== null);
|
|
684
|
+
return { entries, failed, summary: summarize(entries) };
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* The enabled set merged with what ubix says is on disk. Never throws: this
|
|
688
|
+
* answers a Console page, and a page that 500s says less than a row saying
|
|
689
|
+
* why its version is unknown (§5b).
|
|
690
|
+
*/
|
|
691
|
+
async status(enabled, custom = []) {
|
|
692
|
+
const base = rows(custom).map((tool) => ({
|
|
693
|
+
source: "binary",
|
|
694
|
+
kind: tool.kind,
|
|
695
|
+
name: tool.name,
|
|
696
|
+
summary: tool.summary,
|
|
697
|
+
enabled: enabled.includes(tool.name),
|
|
698
|
+
binary: { spec: specOf(tool.toml) ?? "", installed: false, version: null, path: null, error: null },
|
|
699
|
+
...(tool.custom ? { custom: true } : {}),
|
|
700
|
+
}));
|
|
701
|
+
// No ubix yet is not a failure — it is the state of an instance that has
|
|
702
|
+
// never switched a tool on.
|
|
703
|
+
if (!existsSync(this.ubixPath))
|
|
704
|
+
return base;
|
|
705
|
+
let states;
|
|
706
|
+
try {
|
|
707
|
+
states = await this.#states(this.ubixPath, this.#env(), ["list", "--json"]);
|
|
708
|
+
}
|
|
709
|
+
catch (err) {
|
|
710
|
+
// The page says why it cannot answer rather than answering wrongly: a
|
|
711
|
+
// row drawn as "not installed" because a read failed is the lie §5b is
|
|
712
|
+
// about.
|
|
713
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
714
|
+
return base.map((entry) => withBinary(entry, { error }));
|
|
715
|
+
}
|
|
716
|
+
return base.map((entry) => {
|
|
717
|
+
const state = states.find((s) => s.name === entry.name);
|
|
718
|
+
if (!state)
|
|
719
|
+
return entry;
|
|
720
|
+
// State says installed, disk says otherwise: broken, and drawing that as
|
|
721
|
+
// ready is how an operator finds out from a failed turn instead.
|
|
722
|
+
const gone = state.installed === true && state.exists === false;
|
|
723
|
+
// Installed, and not where Pier's PATH points: `npm:` lands in fnm's
|
|
724
|
+
// node prefix and `pixi:` in its own, under the package's binary name.
|
|
725
|
+
// The install worked and the promise did not, which is a sentence the
|
|
726
|
+
// row has to say rather than a path an operator has to notice.
|
|
727
|
+
const elsewhere = state.path !== null && !state.path.startsWith(`${this.bin}/`);
|
|
728
|
+
return withBinary(entry, {
|
|
729
|
+
installed: state.installed === true && !gone,
|
|
730
|
+
version: state.version,
|
|
731
|
+
path: state.path,
|
|
732
|
+
error: gone
|
|
733
|
+
? `installed but missing on disk: ${state.missingPaths.join(", ") || "tracked paths are gone"}`
|
|
734
|
+
: state.error ??
|
|
735
|
+
(elsewhere
|
|
736
|
+
? `installed outside Pier's bin (${state.path ?? ""}) — this source installs into its own runtime's` +
|
|
737
|
+
` prefix, so Pier does not put it on the PATH sessions inherit`
|
|
738
|
+
: null),
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
/** Pier's ubix config and state, never the operator's. `UBIX_CONFIG_DIR` /
|
|
743
|
+
* `UBIX_DATA_DIR` name the directories that hold config.toml / state.toml
|
|
744
|
+
* directly — not XDG parents, which every child ubix spawns (uv, fnm,
|
|
745
|
+
* cargo) would read too. */
|
|
746
|
+
#env() {
|
|
747
|
+
const env = {
|
|
748
|
+
...process.env,
|
|
749
|
+
UBIX_CONFIG_DIR: this.#configDir,
|
|
750
|
+
UBIX_DATA_DIR: join(this.#root, "state"),
|
|
751
|
+
};
|
|
752
|
+
prependPath(env, this.bin);
|
|
753
|
+
return env;
|
|
754
|
+
}
|
|
755
|
+
get #configDir() {
|
|
756
|
+
return join(this.#root, "config");
|
|
757
|
+
}
|
|
758
|
+
#writeConfig(tools) {
|
|
759
|
+
mkdirSync(this.#configDir, { recursive: true });
|
|
760
|
+
mkdirSync(join(this.#root, "state"), { recursive: true });
|
|
761
|
+
// Temp plus rename: ubix reads this file, and half a config is a config
|
|
762
|
+
// that declares half the tools.
|
|
763
|
+
const path = join(this.#configDir, "config.toml");
|
|
764
|
+
writeFileSync(`${path}.writing`, ubixConfigToml(tools, this.bin));
|
|
765
|
+
renameSync(`${path}.writing`, path);
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* One ubix call and the document it owes us.
|
|
769
|
+
*
|
|
770
|
+
* A non-zero exit is *not* a reason to skip the parse: under `--json` a tool
|
|
771
|
+
* that failed lands in the document as `action: "failed"` with its error and
|
|
772
|
+
* the run still exits non-zero, so the report says which tool it was. But the
|
|
773
|
+
* two have to agree: an exit code with no failed entry anywhere is a failure
|
|
774
|
+
* this file cannot attribute, and passing it on as a clean report is the one
|
|
775
|
+
* thing it may never do. No document at all is the third case — an ubix
|
|
776
|
+
* release that predates `--json` — and that is said in one sentence rather
|
|
777
|
+
* than by scraping the human output it printed instead.
|
|
778
|
+
*/
|
|
779
|
+
async #states(ubix, env, args) {
|
|
780
|
+
const result = await this.#exec(ubix, args, env);
|
|
781
|
+
let states;
|
|
782
|
+
try {
|
|
783
|
+
states = parseUbixJson(result.stdout);
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
const failure = failedRun(`ubix ${args.join(" ")}`, result);
|
|
787
|
+
// Only the flag being unknown means "too old". Everything else — a
|
|
788
|
+
// malformed body in someone's block, a locked state file, a full disk —
|
|
789
|
+
// is that failure, reported as itself: re-bootstrapping ubix over a
|
|
790
|
+
// config error would fix nothing and say something false.
|
|
791
|
+
if (result.code !== 0 && refusesJson(result.stderr))
|
|
792
|
+
throw new UbixTooOld(failure);
|
|
793
|
+
throw new Error(result.code === 0 ? String(err) : `${failure} (${String(err)})`);
|
|
794
|
+
}
|
|
795
|
+
// Something failed and the report names nothing that did: the two disagree,
|
|
796
|
+
// and believing the document is how a failed run is read as a machine that
|
|
797
|
+
// is fine.
|
|
798
|
+
if (result.code !== 0 && !states.some((state) => state.action === "failed")) {
|
|
799
|
+
throw new Error(`${failedRun(`ubix ${args.join(" ")}`, result)} — and its report names no failure`);
|
|
800
|
+
}
|
|
801
|
+
return states;
|
|
802
|
+
}
|
|
803
|
+
/** The declared tools, and the one place a too-old ubix is repaired rather
|
|
804
|
+
* than reported: Pier put that binary in `bin/`, so replacing it is Pier's
|
|
805
|
+
* job, not an errand for whoever flipped a switch. */
|
|
806
|
+
async #listing(ubix, env) {
|
|
807
|
+
try {
|
|
808
|
+
return await this.#states(ubix, env, ["list", "--json"]);
|
|
809
|
+
}
|
|
810
|
+
catch (err) {
|
|
811
|
+
if (!(err instanceof UbixTooOld))
|
|
812
|
+
throw err;
|
|
813
|
+
log.warn(`the ubix in ${this.bin} is too old for --json — replacing it`);
|
|
814
|
+
return this.#states(await this.bootstrapUbix(true), env, ["list", "--json"]);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/** Run a tool's own binary against its own footprint. Returns the failure
|
|
818
|
+
* text, or null. */
|
|
819
|
+
async #provision(env, tool, args) {
|
|
820
|
+
const exe = join(this.bin, tool.name);
|
|
821
|
+
if (!existsSync(exe))
|
|
822
|
+
return `${tool.name} is not in ${this.bin} — ${args.join(" ")} was not run`;
|
|
823
|
+
// Asserted, not assumed: main.ts exports PI_CODING_AGENT_DIR to every
|
|
824
|
+
// child, but `pier tools sync` typed in a shell has no such parent, and
|
|
825
|
+
// rtk would then write its extension into ~/.pi — a directory this Pier
|
|
826
|
+
// never reads.
|
|
827
|
+
const agentDir = resolveAgentDir(env);
|
|
828
|
+
if (env.PI_CODING_AGENT_DIR !== agentDir) {
|
|
829
|
+
log.info(`PI_CODING_AGENT_DIR was ${env.PI_CODING_AGENT_DIR ?? "unset"} — ${tool.name} gets ${agentDir}`);
|
|
830
|
+
}
|
|
831
|
+
mkdirSync(join(agentDir, "extensions"), { recursive: true });
|
|
832
|
+
const result = await this.#exec(exe, args, { ...env, PI_CODING_AGENT_DIR: agentDir });
|
|
833
|
+
return result.code === 0 ? null : failedRun(`${tool.name} ${args.join(" ")}`, result);
|
|
834
|
+
}
|
|
835
|
+
async #getText(url) {
|
|
836
|
+
return new TextDecoder().decode(await this.#getBytes(url));
|
|
837
|
+
}
|
|
838
|
+
async #getBytes(url) {
|
|
839
|
+
const res = await this.#fetch(url, {
|
|
840
|
+
// GitHub refuses an anonymous request with no user agent.
|
|
841
|
+
headers: { "user-agent": "pier", accept: "application/vnd.github+json" },
|
|
842
|
+
signal: AbortSignal.timeout(60_000),
|
|
843
|
+
});
|
|
844
|
+
if (!res.ok)
|
|
845
|
+
throw new Error(`GET ${url} → ${String(res.status)} ${res.statusText}`);
|
|
846
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/** One catalog entry with its binary block updated. Everything this file makes
|
|
850
|
+
* is a `source: "binary"` entry; the bundled half of the catalog comes from
|
|
851
|
+
* extensions/ and never passes through here. */
|
|
852
|
+
const withBinary = (entry, patch) => entry.source === "binary" ? { ...entry, binary: { ...entry.binary, ...patch } } : entry;
|
|
853
|
+
/** What a failed child said, in one line — the same shape wherever one fails,
|
|
854
|
+
* and never empty: an exit code with no words is not a report. */
|
|
855
|
+
const failedRun = (what, result) => `${what} exited ${String(result.code)}: ${result.stderr.trim().slice(0, 300) || "(no output)"}`;
|
|
856
|
+
/** `<sha256> <bare filename>` lines, as `sha256sum` writes them. */
|
|
857
|
+
function expectedSha256(checksums, file) {
|
|
858
|
+
for (const line of checksums.split("\n")) {
|
|
859
|
+
const [hash, name] = line.trim().split(/\s+/);
|
|
860
|
+
if (hash && name?.replace(/^\*/, "") === file)
|
|
861
|
+
return hash.toLowerCase();
|
|
862
|
+
}
|
|
863
|
+
throw new Error(`checksums.txt names no ${file} — refusing to install an unverified binary`);
|
|
864
|
+
}
|
|
865
|
+
/** What a person reads in the task run. One line per tool, failures included:
|
|
866
|
+
* a sync that says nothing is a sync nobody can tell from a crash. */
|
|
867
|
+
function summarize(entries) {
|
|
868
|
+
if (!entries.length)
|
|
869
|
+
return "no tools switched on";
|
|
870
|
+
return entries
|
|
871
|
+
.map((entry) => entry.error
|
|
872
|
+
? `${entry.name}: FAILED — ${entry.error}`
|
|
873
|
+
: `${entry.name}: ${entry.action}${entry.version ? ` ${entry.version}` : ""}`)
|
|
874
|
+
.join("\n");
|
|
875
|
+
}
|