@timqi/pier 0.0.9 → 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.
Files changed (59) hide show
  1. package/dist/agent/events.js +53 -7
  2. package/dist/agent/listing.js +253 -0
  3. package/dist/agent/pi.js +177 -28
  4. package/dist/boards/boards.js +65 -16
  5. package/dist/boards/pier.css +1 -1
  6. package/dist/channels/attach.js +87 -0
  7. package/dist/channels/control.js +2 -2
  8. package/dist/channels/lark-api.js +38 -0
  9. package/dist/channels/lark-outbound.js +11 -2
  10. package/dist/channels/slack-api.js +36 -0
  11. package/dist/channels/slack-outbound.js +12 -2
  12. package/dist/channels/slack-tool.js +49 -9
  13. package/dist/channels/telegram-api.js +21 -2
  14. package/dist/channels/telegram.js +23 -8
  15. package/dist/cli.js +34 -0
  16. package/dist/core/identity.js +18 -0
  17. package/dist/core/inbound-file.js +3 -1
  18. package/dist/core/reply.js +2 -1
  19. package/dist/core/router.js +72 -0
  20. package/dist/db.js +78 -0
  21. package/dist/extensions/index.js +5 -2
  22. package/dist/extensions/web/artifacts.js +7 -2
  23. package/dist/extensions/web/tools.js +28 -8
  24. package/dist/limits.js +14 -0
  25. package/dist/main.js +47 -6
  26. package/dist/paths.js +6 -1
  27. package/dist/settings.js +44 -0
  28. package/dist/tasks/agent.js +18 -4
  29. package/dist/tasks/callbacks.js +20 -1
  30. package/dist/tasks/definitions.js +56 -12
  31. package/dist/tasks/execution.js +5 -1
  32. package/dist/tasks/groups.js +4 -4
  33. package/dist/tasks/messages.js +4 -2
  34. package/dist/tasks/runs.js +2 -2
  35. package/dist/tasks/service.js +16 -6
  36. package/dist/tasks/tool.js +0 -12
  37. package/dist/tools-task.js +155 -0
  38. package/dist/tools.js +875 -0
  39. package/dist/web/auth.js +5 -3
  40. package/dist/web/explorer.js +15 -2
  41. package/dist/web/files.js +1 -1
  42. package/dist/web/instance.js +165 -36
  43. package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-xcUrfRRs.js} +1 -1
  44. package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
  45. package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
  46. package/dist/web/public/index.html +5 -8
  47. package/dist/web/public/sw.js +4 -0
  48. package/dist/web/push.js +22 -7
  49. package/dist/web/repos.js +75 -0
  50. package/dist/web/server.js +145 -64
  51. package/dist/web/session-state.js +33 -51
  52. package/dist/web/types.js +5 -0
  53. package/package.json +1 -1
  54. package/skills/pier-boards/SKILL.md +23 -13
  55. package/skills/pier-help/SKILL.md +1 -1
  56. package/skills/pier-slack/SKILL.md +21 -1
  57. package/skills/pier-tasks/SKILL.md +2 -2
  58. package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
  59. package/dist/web/public/assets/index-DYl1xk5y.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
+ }