@timqi/pier 0.0.9 → 0.0.16
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 +7 -1
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +190 -31
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +72 -0
- package/dist/db.js +78 -0
- package/dist/extensions/index.js +5 -2
- package/dist/extensions/web/artifacts.js +7 -2
- package/dist/extensions/web/content.js +5 -0
- package/dist/extensions/web/language.js +5 -0
- package/dist/extensions/web/tools.js +33 -8
- package/dist/limits.js +14 -0
- package/dist/main.js +47 -6
- package/dist/paths.js +6 -1
- package/dist/settings.js +44 -0
- package/dist/tasks/agent.js +23 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/command.js +15 -0
- package/dist/tasks/definitions.js +60 -12
- package/dist/tasks/execution.js +9 -1
- package/dist/tasks/groups.js +8 -4
- package/dist/tasks/messages.js +10 -2
- package/dist/tasks/routes.js +4 -0
- package/dist/tasks/runs.js +7 -2
- package/dist/tasks/service.js +22 -6
- package/dist/tasks/store.js +4 -0
- package/dist/tasks/tool.js +0 -12
- package/dist/tasks/types.js +4 -0
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +165 -36
- package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
- package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
- package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +22 -7
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +145 -64
- package/dist/web/session-state.js +33 -51
- package/dist/web/types.js +5 -0
- package/docs/deploy.md +12 -3
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
- package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
package/dist/web/auth.js
CHANGED
|
@@ -118,15 +118,17 @@ const hash = (password, salt) => scryptSync(password, salt, KEY_BYTES).toString(
|
|
|
118
118
|
* What a logged-out visitor must still reach: the login form, published
|
|
119
119
|
* boards, and the stylesheet those boards link — a published board rendering
|
|
120
120
|
* unstyled for the person it was published to is the same bug as not serving
|
|
121
|
-
* it.
|
|
122
|
-
*
|
|
121
|
+
* it. Both live under `/p/*` (the stylesheet at `/p/_assets/pier.css`), the
|
|
122
|
+
* single exempt prefix `docs/architecture.md` reserved for this, so the rule
|
|
123
|
+
* is one prefix here and one prefix in anything fronting Pier; `/boards/*`
|
|
124
|
+
* stays behind the boundary.
|
|
123
125
|
*/
|
|
124
126
|
function isPublic(method, path) {
|
|
125
127
|
if (path === "/login")
|
|
126
128
|
return method === "GET" || method === "HEAD" || method === "POST";
|
|
127
129
|
if (method !== "GET" && method !== "HEAD")
|
|
128
130
|
return false;
|
|
129
|
-
return path.startsWith("/p/")
|
|
131
|
+
return path.startsWith("/p/");
|
|
130
132
|
}
|
|
131
133
|
const sign = (secret, expiresAt) => createHmac("sha256", secret).update(String(expiresAt)).digest("base64url");
|
|
132
134
|
/** Constant-time equality that also hides length: both sides are digested. */
|
package/dist/web/explorer.js
CHANGED
|
@@ -82,8 +82,21 @@ export function registerExplorerRoutes(app) {
|
|
|
82
82
|
branch = (await git(root, "rev-parse", "--abbrev-ref", "HEAD")).trim();
|
|
83
83
|
}
|
|
84
84
|
catch {
|
|
85
|
-
|
|
85
|
+
// not a repo, or no commits yet
|
|
86
|
+
return c.json({ branch: null, refs: [], commits: [], worktrees: [] });
|
|
86
87
|
}
|
|
88
|
+
// Every checkout of this repository, from git rather than from the
|
|
89
|
+
// sessions that happen to live in one: a worktree created ten seconds ago
|
|
90
|
+
// has no session in it yet, and that is exactly when its files are worth
|
|
91
|
+
// opening. Detached heads have no `branch` line, so the path stands alone.
|
|
92
|
+
const worktrees = (await git(root, "worktree", "list", "--porcelain"))
|
|
93
|
+
.split("\n\n")
|
|
94
|
+
.map((block) => {
|
|
95
|
+
const path = /^worktree (.+)$/m.exec(block)?.[1];
|
|
96
|
+
const on = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
|
|
97
|
+
return path ? { path, ...(on ? { branch: on } : {}) } : null;
|
|
98
|
+
})
|
|
99
|
+
.filter((w) => w !== null);
|
|
87
100
|
const refs = (await git(root, "for-each-ref", "--format=%(refname:short)\t%(subject)", "refs/heads", "refs/tags"))
|
|
88
101
|
.split("\n")
|
|
89
102
|
.filter(Boolean)
|
|
@@ -100,7 +113,7 @@ export function registerExplorerRoutes(app) {
|
|
|
100
113
|
const [hash = "", at = "", author = "", email = "", subject = "", body = ""] = r.split("\u001f");
|
|
101
114
|
return { hash, at: Number(at) * 1000, author, email, subject, body: body.trim() };
|
|
102
115
|
});
|
|
103
|
-
return c.json({ branch, refs, commits });
|
|
116
|
+
return c.json({ branch, refs, commits, worktrees });
|
|
104
117
|
});
|
|
105
118
|
// One endpoint, two shapes: without `file` the changed-file list
|
|
106
119
|
// (name-status), with it that file's unified diff. `head` empty or absent
|
package/dist/web/files.js
CHANGED
|
@@ -96,7 +96,7 @@ export function registerFileRoutes(app, { factory, config, nascentCwd, onConfigW
|
|
|
96
96
|
/** Real path of a file a session may expose: inside its cwd or the inbox
|
|
97
97
|
* (where inbound user attachments land — core/inbox.ts), nothing else. */
|
|
98
98
|
const resolveFile = async (id, raw) => {
|
|
99
|
-
const cwd = nascentCwd(id) ?? (await factory.
|
|
99
|
+
const cwd = nascentCwd(id) ?? (await factory.find(id))?.cwd;
|
|
100
100
|
if (!cwd || !isAbsolute(raw))
|
|
101
101
|
return null;
|
|
102
102
|
try {
|
package/dist/web/instance.js
CHANGED
|
@@ -2,12 +2,24 @@
|
|
|
2
2
|
// layer-1 secrets control, the browser's error reports. Nothing here touches
|
|
3
3
|
// a session; server.ts stays the session/event surface.
|
|
4
4
|
import { logger } from "../log.js";
|
|
5
|
-
import {
|
|
5
|
+
import { normalizeModelMenu, normalizePublicUrl, normalizeTerminalInitCommand, } from "../settings.js";
|
|
6
|
+
/** A rule that failed inside the transaction — its own class so the route can
|
|
7
|
+
* tell it from a real fault. 400 when the request named something this Pier
|
|
8
|
+
* does not have, 409 when it collided with the state as it stands. */
|
|
9
|
+
class Refusal extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(message, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** One name added to or removed from a stored set, order preserved. */
|
|
17
|
+
const withName = (current, { name, on }) => on ? [...new Set([...current, name])] : current.filter((each) => each !== name);
|
|
6
18
|
/** Client reports per minute, for the whole server: a browser bug can fire in
|
|
7
19
|
* a loop, and the journal is shared with everything else Pier says. */
|
|
8
20
|
const CLIENT_LOG_PER_MINUTE = 60;
|
|
9
21
|
export function registerInstanceRoutes(app, deps) {
|
|
10
|
-
const { settings, updates, updater = null, secrets, extensions, onUnlocked, onSettingsChanged } = deps;
|
|
22
|
+
const { settings, updates, updater = null, secrets, catalog, names = { extensions: [], tools: [] }, onUnlocked, onSettingsChanged, onToolsChanged, validateCustomTools, } = deps;
|
|
11
23
|
const updateLog = logger("update");
|
|
12
24
|
// How long POST /api/update may hold its response open. A busy Pier drains
|
|
13
25
|
// first, which can take minutes, and a response held that long dies at every
|
|
@@ -42,8 +54,17 @@ export function registerInstanceRoutes(app, deps) {
|
|
|
42
54
|
// The catalog rides along: one round trip for the whole page, and the
|
|
43
55
|
// switches cannot disagree with the setting they are drawn from. One shape
|
|
44
56
|
// for both the read and the write, or the page reconciles two answers.
|
|
45
|
-
const instanceSettings = () =>
|
|
46
|
-
|
|
57
|
+
const instanceSettings = async () => {
|
|
58
|
+
const shown = await catalog?.();
|
|
59
|
+
return {
|
|
60
|
+
...settings.get(),
|
|
61
|
+
catalog: shown?.entries ?? [],
|
|
62
|
+
/** Where the runs are: the install and every daily update is one task's
|
|
63
|
+
* history, not a second status surface this route invented. */
|
|
64
|
+
toolsTaskId: shown?.toolsTaskId ?? null,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
app.get("/api/settings", async (c) => c.json(await instanceSettings()));
|
|
47
68
|
// What the version badge reads: the two versions, whether this instance can
|
|
48
69
|
// do anything about the gap, and whether it is allowed to do it unattended.
|
|
49
70
|
// `statusNow` so a browser opened seconds after a restart is told the truth
|
|
@@ -103,56 +124,164 @@ export function registerInstanceRoutes(app, deps) {
|
|
|
103
124
|
});
|
|
104
125
|
// Partial on purpose: each surface sends only the setting it edits, and a
|
|
105
126
|
// malformed field is rejected before anything is written.
|
|
127
|
+
//
|
|
128
|
+
// A switch sends a *delta* (`tool`/`extension`), not the list it computed:
|
|
129
|
+
// two quick clicks each carry a list built from what the page knew a moment
|
|
130
|
+
// ago, so the second silently drops the first.
|
|
106
131
|
app.put("/api/settings", async (c) => {
|
|
107
132
|
const body = await c.req.json().catch(() => null);
|
|
108
|
-
const
|
|
109
|
-
[body.publicUrl, body.modelMenu, body.autoUpdate, body.terminalInitCommand, body.
|
|
110
|
-
|
|
111
|
-
if (!
|
|
133
|
+
const fields = body
|
|
134
|
+
? [body.publicUrl, body.modelMenu, body.autoUpdate, body.terminalInitCommand, body.customTools, body.extension, body.tool]
|
|
135
|
+
: [];
|
|
136
|
+
if (!fields.some((v) => v !== undefined)) {
|
|
112
137
|
return c.json({
|
|
113
|
-
error: "publicUrl, modelMenu, autoUpdate, terminalInitCommand or
|
|
138
|
+
error: "publicUrl, modelMenu, autoUpdate, terminalInitCommand, customTools, extension or tool required",
|
|
114
139
|
}, 400);
|
|
115
140
|
}
|
|
116
|
-
|
|
141
|
+
// Everything is validated before anything is written, and everything is
|
|
142
|
+
// written in one transaction: a request carrying a new custom tool *and*
|
|
143
|
+
// the switch that turns it on must not leave one of the two stored.
|
|
144
|
+
const writes = [];
|
|
145
|
+
const refuse = (error) => c.json({ error }, 400);
|
|
146
|
+
if (body?.publicUrl !== undefined) {
|
|
117
147
|
if (typeof body.publicUrl !== "string")
|
|
118
|
-
return
|
|
148
|
+
return refuse("publicUrl must be a string");
|
|
119
149
|
const publicUrl = normalizePublicUrl(body.publicUrl);
|
|
120
|
-
if (publicUrl === null)
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
settings.setPublicUrl(publicUrl);
|
|
150
|
+
if (publicUrl === null)
|
|
151
|
+
return refuse("not a URL: expected http(s)://host, no query or fragment");
|
|
152
|
+
writes.push(() => settings.setPublicUrl(publicUrl));
|
|
124
153
|
}
|
|
125
|
-
if (body
|
|
154
|
+
if (body?.modelMenu !== undefined) {
|
|
126
155
|
const menu = normalizeModelMenu(body.modelMenu);
|
|
127
|
-
if (menu === null)
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
settings.setModelMenu(menu);
|
|
156
|
+
if (menu === null)
|
|
157
|
+
return refuse("modelMenu must be [{provider, id, note?}] (≤32 entries)");
|
|
158
|
+
writes.push(() => settings.setModelMenu(menu));
|
|
131
159
|
}
|
|
132
|
-
if (body
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
160
|
+
if (body?.autoUpdate !== undefined) {
|
|
161
|
+
const { autoUpdate } = body;
|
|
162
|
+
if (typeof autoUpdate !== "boolean")
|
|
163
|
+
return refuse("autoUpdate must be a boolean");
|
|
164
|
+
writes.push(() => settings.setAutoUpdate(autoUpdate));
|
|
136
165
|
}
|
|
137
|
-
if (body
|
|
166
|
+
if (body?.terminalInitCommand !== undefined) {
|
|
138
167
|
const command = normalizeTerminalInitCommand(body.terminalInitCommand);
|
|
139
|
-
if (command === null)
|
|
140
|
-
return
|
|
141
|
-
|
|
142
|
-
|
|
168
|
+
if (command === null)
|
|
169
|
+
return refuse("terminalInitCommand must be one line of at most 500 characters");
|
|
170
|
+
writes.push(() => settings.setTerminalInitCommand(command));
|
|
171
|
+
}
|
|
172
|
+
/** The blocks this request declares, or null when it does not touch them.
|
|
173
|
+
* Adding a tool is declaring it *and* switching it on, so a name declared
|
|
174
|
+
* here is switchable in the same write. */
|
|
175
|
+
let declared = null;
|
|
176
|
+
if (body?.customTools !== undefined) {
|
|
177
|
+
const validated = validateCustomTools?.(body.customTools) ??
|
|
178
|
+
{ error: "this Pier cannot store custom tools" };
|
|
179
|
+
if ("error" in validated)
|
|
180
|
+
return refuse(validated.error);
|
|
181
|
+
declared = validated.tools;
|
|
182
|
+
const custom = validated.tools;
|
|
183
|
+
writes.push(() => settings.setCustomTools(custom));
|
|
184
|
+
}
|
|
185
|
+
/** A single switch, applied to the set as it is *now* rather than to the
|
|
186
|
+
* list the browser had. Returns the name, or the refusal. */
|
|
187
|
+
const delta = (raw) => {
|
|
188
|
+
const given = typeof raw === "object" && raw !== null ? raw : null;
|
|
189
|
+
const name = typeof given?.name === "string" ? given.name.trim() : "";
|
|
190
|
+
if (!name || name.length > 64 || typeof given?.on !== "boolean")
|
|
191
|
+
return "expected {name, on}";
|
|
192
|
+
return { name, on: given.on };
|
|
193
|
+
};
|
|
194
|
+
let extensionOne = null;
|
|
195
|
+
if (body?.extension !== undefined) {
|
|
196
|
+
const one = delta(body.extension);
|
|
197
|
+
if (typeof one === "string")
|
|
198
|
+
return refuse(`extension: ${one}`);
|
|
199
|
+
extensionOne = one;
|
|
200
|
+
writes.push(() => settings.setExtensions(withName(settings.get().extensions, one)));
|
|
143
201
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
202
|
+
// Whether the enabled set moved, for the install below. The delta itself
|
|
203
|
+
// is resolved inside the transaction, against the set as it was stored.
|
|
204
|
+
let toolsChanged = false;
|
|
205
|
+
let toolOne = null;
|
|
206
|
+
if (body?.tool !== undefined) {
|
|
207
|
+
const one = delta(body.tool);
|
|
208
|
+
if (typeof one === "string")
|
|
209
|
+
return refuse(`tool: ${one}`);
|
|
210
|
+
toolOne = one;
|
|
211
|
+
toolsChanged = true;
|
|
212
|
+
writes.push(() => settings.setTools(withName(settings.get().tools, one)));
|
|
213
|
+
}
|
|
214
|
+
// What ubix says about the blocks this request would undeclare. Asked out
|
|
215
|
+
// here because answering it spawns a subprocess, and a transaction may not
|
|
216
|
+
// wait on one; the *sets* below are read inside the transaction, where
|
|
217
|
+
// nothing can move them.
|
|
218
|
+
const dropping = declared !== null &&
|
|
219
|
+
settings.get().customTools.some((tool) => !declared?.some((kept) => kept.name === tool.name));
|
|
220
|
+
const shown = dropping ? await catalog?.() : undefined;
|
|
221
|
+
const binaryOf = (name) => {
|
|
222
|
+
const entry = shown?.entries.find((row) => row.source === "binary" && row.name === name);
|
|
223
|
+
return entry?.source === "binary" ? entry.binary : null;
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* The rules that are about *sets*, applied inside the transaction against
|
|
227
|
+
* the settings as they are at that instant. Outside it they are a
|
|
228
|
+
* time-of-check: a request dropping a declaration and another switching
|
|
229
|
+
* that tool on both passed, and left an enabled tool nothing declares.
|
|
230
|
+
*
|
|
231
|
+
* What may be switched on comes from `names`, which is code and cannot
|
|
232
|
+
* change while this runs, plus what this request declares. What may be
|
|
233
|
+
* undeclared is the second rule: a block is the only thing that can
|
|
234
|
+
* uninstall its binary, so it may not go while the tool is on, installed,
|
|
235
|
+
* broken, or while ubix's answer about it could not be read. Switching a
|
|
236
|
+
* name *off* is never refused — that is the repair, and it is the first
|
|
237
|
+
* half of removing one.
|
|
238
|
+
*/
|
|
239
|
+
const check = () => {
|
|
240
|
+
const current = settings.get();
|
|
241
|
+
const after = (declared ?? current.customTools).map((tool) => tool.name);
|
|
242
|
+
const toolsAfter = toolOne ? withName(current.tools, toolOne) : current.tools;
|
|
243
|
+
if (extensionOne?.on && !names.extensions.includes(extensionOne.name)) {
|
|
244
|
+
throw new Refusal(`extension: this Pier has no extension called ${extensionOne.name}`, 400);
|
|
245
|
+
}
|
|
246
|
+
if (toolOne?.on && !names.tools.includes(toolOne.name) && !after.includes(toolOne.name)) {
|
|
247
|
+
throw new Refusal(`tool: this Pier manages no tool called ${toolOne.name}`, 400);
|
|
248
|
+
}
|
|
249
|
+
for (const gone of current.customTools.filter((tool) => !after.includes(tool.name))) {
|
|
250
|
+
if (toolsAfter.includes(gone.name)) {
|
|
251
|
+
throw new Refusal(`${gone.name} is still switched on — switch it off first, so the next sync uninstalls it`, 409);
|
|
252
|
+
}
|
|
253
|
+
const binary = binaryOf(gone.name);
|
|
254
|
+
if (!binary)
|
|
255
|
+
throw new Refusal(`${gone.name}: Pier cannot read what is installed, so its block stays for now`, 409);
|
|
256
|
+
if (binary.installed) {
|
|
257
|
+
throw new Refusal(`${gone.name} is still installed — its block stays until ubix reports the binary gone`, 409);
|
|
258
|
+
}
|
|
259
|
+
if (binary.error)
|
|
260
|
+
throw new Refusal(`${gone.name}: ${binary.error} — its block stays until that is resolved`, 409);
|
|
148
261
|
}
|
|
149
|
-
|
|
262
|
+
};
|
|
263
|
+
try {
|
|
264
|
+
settings.transact(() => {
|
|
265
|
+
check();
|
|
266
|
+
for (const write of writes)
|
|
267
|
+
write();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
// Nothing was written: the transaction rolled back with it.
|
|
272
|
+
if (err instanceof Refusal)
|
|
273
|
+
return c.json({ error: err.message }, err.status);
|
|
274
|
+
throw err;
|
|
150
275
|
}
|
|
276
|
+
// Stored first, then acted on: the switch shows what was written even when
|
|
277
|
+
// the install cannot start — and then says why, here, rather than leaving
|
|
278
|
+
// "saved" as the last thing anyone was told.
|
|
279
|
+
const note = toolsChanged ? await onToolsChanged?.() : null;
|
|
151
280
|
// The URL and the extension set are both read when a session opens; the
|
|
152
281
|
// model menu is read per picker call, so it needs no recycle.
|
|
153
|
-
if (body
|
|
282
|
+
if (body?.publicUrl !== undefined || body?.extension !== undefined)
|
|
154
283
|
onSettingsChanged?.();
|
|
155
|
-
return c.json(instanceSettings());
|
|
284
|
+
return c.json({ ...(await instanceSettings()), ...(note ? { toolsSync: note } : {}) });
|
|
156
285
|
});
|
|
157
286
|
// Layer-1 key status and control (Console → Settings → Security). The GET
|
|
158
287
|
// is what a locked instance shows; unlock is how it recovers without a
|