@timqi/pier 0.0.21 → 0.0.23

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 (84) hide show
  1. package/README.md +4 -5
  2. package/dist/agent/config-sync.js +124 -92
  3. package/dist/boards/boards.js +6 -2
  4. package/dist/db.js +12 -3
  5. package/dist/main.js +14 -14
  6. package/dist/service.js +17 -7
  7. package/dist/settings.js +0 -26
  8. package/dist/tasks/callbacks.js +1 -1
  9. package/dist/tasks/definitions.js +4 -0
  10. package/dist/tasks/groups.js +2 -1
  11. package/dist/tasks/service.js +14 -4
  12. package/dist/tasks/tool.js +70 -13
  13. package/dist/web/auth.js +1 -22
  14. package/dist/web/fs.js +2 -2
  15. package/dist/web/instance.js +6 -12
  16. package/dist/web/public/assets/activity-atEIVnGA.js +5 -0
  17. package/dist/web/public/assets/activity-atEIVnGA.js.br +0 -0
  18. package/dist/web/public/assets/activity-atEIVnGA.js.gz +0 -0
  19. package/dist/web/public/assets/{boards-DMVbV84c.js → boards-DSkfMEvN.js} +1 -1
  20. package/dist/web/public/assets/boards-DSkfMEvN.js.br +0 -0
  21. package/dist/web/public/assets/boards-DSkfMEvN.js.gz +0 -0
  22. package/dist/web/public/assets/{code-C86xKk2k.js → code-1nUOCxAb.js} +1 -1
  23. package/dist/web/public/assets/explorer-CS1kLPMl.js +5 -0
  24. package/dist/web/public/assets/explorer-CS1kLPMl.js.br +0 -0
  25. package/dist/web/public/assets/explorer-CS1kLPMl.js.gz +0 -0
  26. package/dist/web/public/assets/index-DP1-BW-q.css +2 -0
  27. package/dist/web/public/assets/index-DP1-BW-q.css.br +0 -0
  28. package/dist/web/public/assets/index-DP1-BW-q.css.gz +0 -0
  29. package/dist/web/public/assets/index-a7A8xfoi.js +83 -0
  30. package/dist/web/public/assets/index-a7A8xfoi.js.br +0 -0
  31. package/dist/web/public/assets/index-a7A8xfoi.js.gz +0 -0
  32. package/dist/web/public/assets/runs-bHSlJwXk.js +1 -0
  33. package/dist/web/public/assets/runs-bHSlJwXk.js.br +0 -0
  34. package/dist/web/public/assets/runs-bHSlJwXk.js.gz +0 -0
  35. package/dist/web/public/assets/settings-CrC-yuXh.js +6 -0
  36. package/dist/web/public/assets/settings-CrC-yuXh.js.br +0 -0
  37. package/dist/web/public/assets/settings-CrC-yuXh.js.gz +0 -0
  38. package/dist/web/public/assets/task-runs-C2PHAhAf.js +3 -0
  39. package/dist/web/public/assets/task-runs-C2PHAhAf.js.br +0 -0
  40. package/dist/web/public/assets/task-runs-C2PHAhAf.js.gz +0 -0
  41. package/dist/web/public/assets/tasks-7S90iYr1.js +4 -0
  42. package/dist/web/public/assets/tasks-7S90iYr1.js.br +0 -0
  43. package/dist/web/public/assets/tasks-7S90iYr1.js.gz +0 -0
  44. package/dist/web/public/index.html +16 -24
  45. package/dist/web/public/index.html.br +0 -0
  46. package/dist/web/public/index.html.gz +0 -0
  47. package/dist/web/server.js +4 -4
  48. package/docs/deploy.md +23 -15
  49. package/package.json +2 -9
  50. package/skills/pier-tasks/SKILL.md +24 -13
  51. package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
  52. package/dist/web/public/assets/activity-Cw0LRVNI.js +0 -5
  53. package/dist/web/public/assets/activity-Cw0LRVNI.js.br +0 -0
  54. package/dist/web/public/assets/activity-Cw0LRVNI.js.gz +0 -0
  55. package/dist/web/public/assets/boards-DMVbV84c.js.br +0 -0
  56. package/dist/web/public/assets/boards-DMVbV84c.js.gz +0 -0
  57. package/dist/web/public/assets/explorer-DTvsy5Rs.js +0 -5
  58. package/dist/web/public/assets/explorer-DTvsy5Rs.js.br +0 -0
  59. package/dist/web/public/assets/explorer-DTvsy5Rs.js.gz +0 -0
  60. package/dist/web/public/assets/ghostty-web-CvSZragH.js +0 -13
  61. package/dist/web/public/assets/ghostty-web-CvSZragH.js.br +0 -0
  62. package/dist/web/public/assets/ghostty-web-CvSZragH.js.gz +0 -0
  63. package/dist/web/public/assets/index-DHxoYoeq.js +0 -79
  64. package/dist/web/public/assets/index-DHxoYoeq.js.br +0 -0
  65. package/dist/web/public/assets/index-DHxoYoeq.js.gz +0 -0
  66. package/dist/web/public/assets/index-DuZ2vERd.css +0 -2
  67. package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
  68. package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
  69. package/dist/web/public/assets/runs-jY40JBP-.js +0 -1
  70. package/dist/web/public/assets/runs-jY40JBP-.js.br +0 -0
  71. package/dist/web/public/assets/runs-jY40JBP-.js.gz +0 -0
  72. package/dist/web/public/assets/settings-Dux7PIKF.js +0 -6
  73. package/dist/web/public/assets/settings-Dux7PIKF.js.br +0 -0
  74. package/dist/web/public/assets/settings-Dux7PIKF.js.gz +0 -0
  75. package/dist/web/public/assets/task-runs-DKvtBVxS.js +0 -3
  76. package/dist/web/public/assets/task-runs-DKvtBVxS.js.br +0 -0
  77. package/dist/web/public/assets/task-runs-DKvtBVxS.js.gz +0 -0
  78. package/dist/web/public/assets/tasks-CqfrOVIt.js +0 -4
  79. package/dist/web/public/assets/tasks-CqfrOVIt.js.br +0 -0
  80. package/dist/web/public/assets/tasks-CqfrOVIt.js.gz +0 -0
  81. package/dist/web/public/assets/terminal-DL_0yEy7.js +0 -2
  82. package/dist/web/public/assets/terminal-DL_0yEy7.js.br +0 -0
  83. package/dist/web/public/assets/terminal-DL_0yEy7.js.gz +0 -0
  84. package/dist/web/terminal.js +0 -369
@@ -1,369 +0,0 @@
1
- // Terminal backend: one shell pty per project cwd, mirrored to every attached
2
- // WebSocket. The pty outlives the page — closing a tab only detaches, and a
3
- // later attach replays the recent output ring; every attached page sees the
4
- // same shell, and any of them may type (last resize wins). Detached for an
5
- // hour → the shell is reaped; a Pier restart kills every pty (run tmux inside
6
- // to survive that — durability is tmux's job, not ours). This is the one
7
- // WebSocket surface: a keystroke is a round trip, which SSE cannot carry
8
- // upstream. Everything else stays on the event stream.
9
- import { spawn } from "node-pty";
10
- import { WebSocketServer } from "ws";
11
- import { logger } from "../log.js";
12
- import { scoped } from "./fs.js";
13
- import { ALL, upgradeAuthorized } from "./auth.js";
14
- const log = logger("terminal");
15
- /** Live shells at once — a guard against forgotten spawns, not a quota. */
16
- const MAX_TERMS = 8;
17
- /** Recent output kept for reattach. Scrollback beyond it lives in the page. */
18
- const RING_BYTES = 1024 * 1024;
19
- const IDLE_MS = 60 * 60_000;
20
- const SWEEP_MS = 60_000;
21
- const HEARTBEAT_MS = 30_000;
22
- /** A slow mirror is detached before its send queue grows without bound; a
23
- * healthy page must never be paused behind it. Reattach replays the ring. */
24
- const HIGH_WATER = 4 * 1024 * 1024;
25
- const MAX_FRAME_BYTES = 1024 * 1024;
26
- // A Web Terminal is a new local tty, not the tmux/SSH client Pier happened to
27
- // start under. Inheriting these can attach the shell back into Pier's parent
28
- // tmux session; SSH_AUTH_SOCK deliberately stays so git/ssh keep working.
29
- const PARENT_TERMINAL_ENV = ["TMUX", "TMUX_PANE", "SSH_TTY", "SSH_CLIENT", "SSH_CONNECTION"];
30
- // Pier's own configuration is Pier's, not this shell's. `NODE_ENV=production`
31
- // alone turns an `npm i` typed here into an install with no dev dependencies,
32
- // and every `PI_*`/`PIER_*` would point a `pi` started here at Pier's own
33
- // instance rather than the person's. Everything else is inherited on purpose:
34
- // PATH, LANG, SSH_AUTH_SOCK and the session's XDG/DBUS handles are what make
35
- // the shell usable, and on a service-managed instance nothing else supplies
36
- // them.
37
- const PIER_OWN_ENV = ["NODE_ENV", "PORT", "HOST"];
38
- const PIER_OWN_PREFIX = /^PI(ER)?_/;
39
- const send = (sock, data) => {
40
- try {
41
- sock.send(data);
42
- return true;
43
- }
44
- catch (err) {
45
- log.warn(`terminal socket send failed: ${String(err)}`);
46
- return false;
47
- }
48
- };
49
- const control = (sock, msg) => send(sock, JSON.stringify(msg));
50
- export class TerminalHub {
51
- #terms = new Map();
52
- #shell;
53
- #idleMs;
54
- #maxTerms;
55
- #initCommand;
56
- #sweeper;
57
- #closeListeners = new Set();
58
- #closed = false;
59
- constructor(opts = {}) {
60
- this.#shell = opts.shell ?? process.env.SHELL ?? "/bin/bash";
61
- this.#idleMs = opts.idleMs ?? IDLE_MS;
62
- this.#maxTerms = opts.maxTerms ?? MAX_TERMS;
63
- this.#initCommand = opts.initCommand ?? (() => "");
64
- this.#sweeper = setInterval(() => this.sweep(Date.now()), SWEEP_MS);
65
- this.#sweeper.unref();
66
- }
67
- /** Attach a socket to `cwd`'s shell, spawning one if needed. A refusal is
68
- * never silent: the socket gets a `{t:"error"}` frame and a close, and the
69
- * caller gets `null`. */
70
- async attach(cwd, sock) {
71
- let key;
72
- try {
73
- key = await scoped(cwd); // absolute, real, and a directory — or it throws
74
- }
75
- catch (err) {
76
- control(sock, { t: "error", message: String(err) });
77
- sock.close(1008, "bad cwd");
78
- return null;
79
- }
80
- if (this.#closed) {
81
- control(sock, { t: "error", message: "terminal server is stopping" });
82
- sock.close(1012, "server stopping");
83
- return null;
84
- }
85
- let term = this.#terms.get(key);
86
- if (!term) {
87
- if (this.#terms.size >= this.#maxTerms) {
88
- control(sock, { t: "error", message: `${this.#maxTerms} shells already running — close one first` });
89
- sock.close(1013, "too many shells");
90
- return null;
91
- }
92
- try {
93
- term = this.#spawn(key);
94
- }
95
- catch (err) {
96
- log.error(`spawn ${this.#shell} in ${key} failed`, err);
97
- control(sock, { t: "error", message: `could not start ${this.#shell}: ${String(err)}` });
98
- sock.close(1011, "spawn failed");
99
- return null;
100
- }
101
- }
102
- term.clients.add(sock);
103
- term.idleSince = Infinity;
104
- const conn = {
105
- message: (text) => this.#message(key, sock, text),
106
- detach: () => {
107
- const t = this.#terms.get(key);
108
- if (!t || !t.clients.delete(sock))
109
- return;
110
- if (t.clients.size === 0)
111
- t.idleSince = Date.now();
112
- },
113
- };
114
- try {
115
- if (term.ringBytes)
116
- sock.send(Buffer.concat(term.ring));
117
- }
118
- catch (err) {
119
- conn.detach();
120
- throw err;
121
- }
122
- return conn;
123
- }
124
- #spawn(cwd) {
125
- const env = { ...process.env };
126
- for (const key of Object.keys(env)) {
127
- if (PARENT_TERMINAL_ENV.includes(key) || PIER_OWN_ENV.includes(key) || PIER_OWN_PREFIX.test(key)) {
128
- delete env[key];
129
- }
130
- }
131
- const pty = spawn(this.#shell, [], {
132
- name: "xterm-256color",
133
- cols: 120,
134
- rows: 30,
135
- cwd,
136
- env,
137
- });
138
- const term = { pty, ring: [], ringBytes: 0, clients: new Set(), idleSince: Infinity };
139
- this.#terms.set(cwd, term);
140
- log.info(`shell ${pty.pid} for ${cwd}`);
141
- // Typed in, not exec'd: the shell stays the parent, so quitting whatever
142
- // this starts leaves a usable prompt, and the echo plus any error is in
143
- // the ring where the person can see what ran. The tty buffers it until the
144
- // shell's first read, so no wait is needed. A reattach never repeats it —
145
- // this runs once per pty, which is once per cwd.
146
- const init = this.#initCommand().trim();
147
- if (init)
148
- pty.write(`${init}\r`);
149
- pty.onData((data) => {
150
- const chunk = Buffer.from(data);
151
- term.ring.push(chunk);
152
- term.ringBytes += chunk.byteLength;
153
- while (term.ringBytes > RING_BYTES && term.ring.length > 1) {
154
- term.ringBytes -= term.ring.shift().byteLength;
155
- }
156
- for (const client of term.clients) {
157
- if (client.bufferedAmount <= HIGH_WATER && send(client, chunk))
158
- continue;
159
- term.clients.delete(client);
160
- client.close(1013, "client too slow");
161
- log.warn(`detached slow terminal client for ${cwd}`);
162
- }
163
- if (term.clients.size === 0 && term.idleSince === Infinity)
164
- term.idleSince = Date.now();
165
- });
166
- pty.onExit(({ exitCode }) => {
167
- log.info(`shell ${pty.pid} for ${cwd} exited (${exitCode})`);
168
- this.#terms.delete(cwd);
169
- for (const c of term.clients) {
170
- control(c, { t: "exit", code: exitCode });
171
- c.close(1000, "shell exited");
172
- }
173
- term.clients.clear();
174
- });
175
- return term;
176
- }
177
- /** One inbound frame. Malformed input is logged and dropped, never half-run. */
178
- #message(cwd, sock, text) {
179
- const term = this.#terms.get(cwd);
180
- if (!term || !term.clients.has(sock))
181
- return;
182
- let raw;
183
- try {
184
- raw = JSON.parse(text);
185
- }
186
- catch {
187
- log.warn(`dropped unparseable frame for ${cwd}`);
188
- return;
189
- }
190
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
191
- log.warn(`dropped malformed frame for ${cwd}: ${text.slice(0, 80)}`);
192
- return;
193
- }
194
- const msg = raw;
195
- if (msg.t === "in" && typeof msg.d === "string") {
196
- term.pty.write(msg.d);
197
- return;
198
- }
199
- // The only way a page can end a shell: everything attached to it is told by
200
- // the exit path below, exactly as if the shell had exited on its own.
201
- if (msg.t === "restart") {
202
- log.info(`shell ${term.pty.pid} for ${cwd} killed on request`);
203
- term.pty.kill();
204
- return;
205
- }
206
- if (msg.t === "resize" &&
207
- typeof msg.cols === "number" && typeof msg.rows === "number" &&
208
- Number.isInteger(msg.cols) && Number.isInteger(msg.rows) &&
209
- msg.cols >= 2 && msg.cols <= 500 && msg.rows >= 2 && msg.rows <= 200) {
210
- term.pty.resize(msg.cols, msg.rows); // last resize wins, by design
211
- return;
212
- }
213
- log.warn(`dropped malformed frame for ${cwd}: ${text.slice(0, 80)}`);
214
- }
215
- /** Reap shells nobody has been attached to for `idleMs`. */
216
- sweep(now) {
217
- for (const [cwd, term] of this.#terms) {
218
- if (now - term.idleSince < this.#idleMs)
219
- continue;
220
- log.info(`reaping idle shell ${term.pty.pid} for ${cwd}`);
221
- term.pty.kill(); // onExit above deletes and notifies
222
- }
223
- }
224
- get size() {
225
- return this.#terms.size;
226
- }
227
- onClose(listener) {
228
- this.#closeListeners.add(listener);
229
- }
230
- /** Shutdown: kill every shell so none outlives the workbench. */
231
- close() {
232
- if (this.#closed)
233
- return;
234
- this.#closed = true;
235
- clearInterval(this.#sweeper);
236
- for (const listener of this.#closeListeners) {
237
- try {
238
- listener();
239
- }
240
- catch (err) {
241
- log.error("terminal shutdown cleanup failed", err);
242
- }
243
- }
244
- this.#closeListeners.clear();
245
- for (const term of this.#terms.values())
246
- term.pty.kill();
247
- }
248
- }
249
- /** The upgrade seam: `/api/terminal?cwd=…` behind the same password boundary
250
- * as every route. SameSite=Lax already withholds the cookie cross-site; the
251
- * Origin check is the explicit copy of that fact. */
252
- export function attachTerminal(server, auth, opts = {}) {
253
- const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
254
- const hub = new TerminalHub(opts);
255
- const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
256
- const alive = new WeakSet();
257
- const heartbeat = setInterval(() => {
258
- for (const client of wss.clients) {
259
- if (client.readyState !== client.OPEN || !alive.delete(client))
260
- client.terminate();
261
- else
262
- client.ping(); // browsers and proxies both see traffic
263
- }
264
- }, heartbeatMs);
265
- heartbeat.unref();
266
- hub.onClose(() => {
267
- clearInterval(heartbeat);
268
- for (const client of wss.clients)
269
- client.terminate();
270
- wss.close();
271
- });
272
- server.once("close", () => hub.close());
273
- wss.on("error", (err) => log.error("terminal WebSocket server failed", err));
274
- // A shell outlives the request that opened it, so revocation has to reach it
275
- // here: the session that opened this socket was signed out (or every session
276
- // was, by a password change), and the terminal goes with it.
277
- const sessionOf = new WeakMap();
278
- const revoked = new WeakSet();
279
- auth.onRevoke((id) => {
280
- for (const client of wss.clients) {
281
- if (id !== ALL && sessionOf.get(client) !== id)
282
- continue;
283
- revoked.add(client);
284
- client.close(1008, "signed out");
285
- }
286
- });
287
- server.on("upgrade", (req, socket, head) => {
288
- let url;
289
- try {
290
- url = new URL(req.url ?? "/", "http://localhost");
291
- }
292
- catch {
293
- log.warn(`refused malformed terminal upgrade target: ${req.url ?? ""}`);
294
- socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
295
- socket.destroy();
296
- return;
297
- }
298
- if (url.pathname !== "/api/terminal") {
299
- socket.destroy();
300
- return;
301
- }
302
- const sessionId = upgradeAuthorized(auth, req);
303
- if (!sessionId) {
304
- log.warn("refused terminal upgrade (unauthorized)");
305
- socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
306
- socket.destroy();
307
- return;
308
- }
309
- wss.handleUpgrade(req, socket, head, (ws) => {
310
- sessionOf.set(ws, sessionId);
311
- alive.add(ws);
312
- ws.on("pong", () => alive.add(ws));
313
- // Frames can land while attach() is resolving realpath; hold a bounded
314
- // handful. Close is registered first so an early disconnect cannot leave
315
- // a phantom client that prevents the one-hour reap.
316
- let conn = null;
317
- let closed = false;
318
- let pendingBytes = 0;
319
- const pending = [];
320
- ws.on("close", () => {
321
- closed = true;
322
- conn?.detach();
323
- });
324
- ws.on("message", (data, binary) => {
325
- if (closed)
326
- return;
327
- // A frame can already be in flight when the close above goes out.
328
- if (revoked.has(ws)) {
329
- closed = true;
330
- ws.close(1008, "signed out");
331
- return;
332
- }
333
- if (binary) {
334
- closed = true;
335
- ws.close(1003, "text frames only");
336
- return;
337
- }
338
- const text = String(data);
339
- if (conn)
340
- conn.message(text);
341
- else if ((pendingBytes += Buffer.byteLength(text)) <= MAX_FRAME_BYTES)
342
- pending.push(text);
343
- else {
344
- closed = true;
345
- ws.close(1009, "too much pending input");
346
- }
347
- });
348
- ws.on("error", (err) => log.warn(`terminal socket error: ${String(err)}`));
349
- void hub.attach(url.searchParams.get("cwd") ?? "", ws).then((c) => {
350
- if (!c)
351
- return;
352
- if (closed) {
353
- c.detach();
354
- return;
355
- }
356
- conn = c;
357
- for (const text of pending)
358
- conn.message(text);
359
- }).catch((err) => {
360
- log.error("terminal attach failed", err);
361
- if (closed)
362
- return;
363
- control(ws, { t: "error", message: `terminal attach failed: ${String(err)}` });
364
- ws.close(1011, "attach failed");
365
- });
366
- });
367
- });
368
- return hub;
369
- }