@timqi/pier 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -125
- package/dist/agent/config.js +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +7 -23
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +4 -12
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
- package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
- package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
- package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
- package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js +85 -0
- package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
- package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
- package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
- package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
- package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
- package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
- package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
- package/dist/web/public/index.html +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/tools.js
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
|
-
// The binaries Pier manages for itself
|
|
2
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
68
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
|
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
|
-
*
|
|
133
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
130
|
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
131
|
const toolsBin = () => toolsDir("bin");
|
|
193
|
-
/**
|
|
194
|
-
*
|
|
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
|
-
*/
|
|
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
|
-
/**
|
|
206
|
-
*
|
|
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
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|
-
/**
|
|
247
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
285
|
-
* bookkeeping
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
481
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
491
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
604
|
-
*
|
|
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
|
-
//
|
|
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
|
|
651
|
-
//
|
|
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
|
|
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`
|
|
672
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
744
|
-
//
|
|
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
|
-
/**
|
|
763
|
-
*
|
|
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
|
-
/**
|
|
781
|
-
*
|
|
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
|
-
//
|
|
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
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
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"
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
862
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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";
|