@timqi/pier 0.0.29 → 0.1.1

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