@timqi/pier 0.0.2 → 0.0.4
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 +26 -13
- package/dist/channels/slack-tool.js +28 -3
- package/dist/cli.js +15 -4
- package/dist/core/router.js +18 -4
- package/dist/main.js +116 -14
- package/dist/service.js +76 -4
- package/dist/settings.js +9 -1
- package/dist/tasks/service.js +13 -0
- package/dist/update.js +59 -7
- package/dist/web/auth.js +14 -4
- package/dist/web/explorer.js +15 -14
- package/dist/web/files.js +2 -1
- package/dist/web/instance.js +73 -7
- package/dist/web/providers.js +16 -4
- package/dist/web/public/assets/index-B3MvJUJP.js +90 -0
- package/dist/web/public/assets/index-CwBoxtXP.css +2 -0
- package/dist/web/public/index.html +10 -7
- package/dist/web/server.js +86 -6
- package/docs/deploy.md +42 -3
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-slack/SKILL.md +17 -1
- package/dist/web/public/assets/index-BK64pHmP.js +0 -90
- package/dist/web/public/assets/index-De4GlOq4.css +0 -2
package/dist/update.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The newer Pier: whether one exists, and when this one may become it.
|
|
2
2
|
//
|
|
3
3
|
// The registry is asked, not the GitHub API: `registry.npmjs.org` is a CDN with
|
|
4
4
|
// no rate limit and no token, and it is the same place `npm i -g` would look,
|
|
5
5
|
// so what it reports is what an update would actually get.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// —
|
|
7
|
+
// Nothing here installs anything. Applying is handed to whatever supervises
|
|
8
|
+
// this process (service.ts's oneshot unit, injected as `apply`): a web server
|
|
9
|
+
// holding provider keys must not npm-install as its own child, and the two
|
|
10
|
+
// gates below — the operator switched it on, and nothing is running — are why
|
|
11
|
+
// a self-replacing timer is not simply a supply-chain surface (AGENTS.md 8).
|
|
11
12
|
import { createRequire } from "node:module";
|
|
12
13
|
import { logger } from "./log.js";
|
|
13
14
|
const log = logger("update");
|
|
14
15
|
const PACKAGE = "@timqi/pier";
|
|
15
16
|
const ENDPOINT = `https://registry.npmjs.org/${PACKAGE}/latest`;
|
|
16
|
-
/**
|
|
17
|
-
|
|
17
|
+
/** One conditional-GET-sized request against a CDN, so the cost of asking is
|
|
18
|
+
* not what sets this — how long a released fix may sit unnoticed is. */
|
|
19
|
+
const TTL_MS = 30 * 60_000;
|
|
18
20
|
const TIMEOUT_MS = 5_000;
|
|
19
21
|
export const currentVersion = () => createRequire(import.meta.url)("../package.json").version;
|
|
20
22
|
export const isValidVersion = (version) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version);
|
|
@@ -59,6 +61,15 @@ export class UpdateCheck {
|
|
|
59
61
|
available: this.#latest !== null && isNewer(this.#latest, this.current),
|
|
60
62
|
};
|
|
61
63
|
}
|
|
64
|
+
/** The answer, waiting for the very first check instead of reporting "no
|
|
65
|
+
* idea". A browser asks once per page load, so a process that had never
|
|
66
|
+
* checked told every one of them `latest: null` — which is exactly how a
|
|
67
|
+
* published release looked undetected. Later loads are served from cache. */
|
|
68
|
+
async statusNow() {
|
|
69
|
+
if (this.#checkedAt === 0)
|
|
70
|
+
await this.refresh();
|
|
71
|
+
return this.status();
|
|
72
|
+
}
|
|
62
73
|
/** Ask now. Concurrent callers share the one request. */
|
|
63
74
|
refresh() {
|
|
64
75
|
this.#inFlight ??= this.fetchLatest()
|
|
@@ -77,6 +88,47 @@ export class UpdateCheck {
|
|
|
77
88
|
return this.#inFlight;
|
|
78
89
|
}
|
|
79
90
|
}
|
|
91
|
+
/** Often enough to catch an idle window on a busy box, and cheap: the registry
|
|
92
|
+
* itself is still only asked once per TTL (`status()` owns that). */
|
|
93
|
+
const AUTO_POLL_MS = 15 * 60_000;
|
|
94
|
+
/** Watch for the moment all three conditions hold. Returns its own stop. */
|
|
95
|
+
export function startAutoUpdate(check, auto, pollMs = AUTO_POLL_MS) {
|
|
96
|
+
// A handover drains first, which can outlast a poll interval; a second
|
|
97
|
+
// attempt on top of it would drain an already-draining Pier.
|
|
98
|
+
let handingOver = false;
|
|
99
|
+
const tick = async () => {
|
|
100
|
+
if (handingOver || !auto.enabled())
|
|
101
|
+
return;
|
|
102
|
+
// Refreshes in the background when stale; today's answer is good enough,
|
|
103
|
+
// because the next tick is a quarter of an hour away either way.
|
|
104
|
+
const { latest, available } = check.status();
|
|
105
|
+
if (!available || !auto.idle())
|
|
106
|
+
return;
|
|
107
|
+
log.info(`auto-update: idle and ${latest ?? "a newer version"} is out — handing over to the updater`);
|
|
108
|
+
handingOver = true;
|
|
109
|
+
try {
|
|
110
|
+
const started = await auto.apply();
|
|
111
|
+
// Not silent (§5b): an update that never happens must not look like an
|
|
112
|
+
// update that was never wanted. `busy` is the one non-start that is
|
|
113
|
+
// fine — someone else is already restarting this Pier.
|
|
114
|
+
if (started === "busy")
|
|
115
|
+
log.info("auto-update: a handover or restart is already in progress");
|
|
116
|
+
else if (started !== "started")
|
|
117
|
+
log.error(`auto-update could not start: ${started}`);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
log.error("auto-update failed", err);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
// Only reached when the handover did *not* take the process with it, so
|
|
124
|
+
// the next tick is allowed to try again.
|
|
125
|
+
handingOver = false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const timer = setInterval(() => void tick(), pollMs);
|
|
129
|
+
timer.unref(); // a pending check must never be what keeps the process alive
|
|
130
|
+
return () => clearInterval(timer);
|
|
131
|
+
}
|
|
80
132
|
export async function fetchLatestVersion() {
|
|
81
133
|
// Plain JSON: the abbreviated-packument content type npm uses for a whole
|
|
82
134
|
// package is a 406 on this endpoint, which answers one version already.
|
package/dist/web/auth.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// header) because the workbench lives on SSE, and EventSource sends no headers.
|
|
18
18
|
import { createHash, createHmac, randomBytes, randomInt, scryptSync, timingSafeEqual, } from "node:crypto";
|
|
19
19
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
20
|
-
import { getCookie, setCookie } from "hono/cookie";
|
|
20
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
21
21
|
import { pierDb } from "../db.js";
|
|
22
22
|
import { logger } from "../log.js";
|
|
23
23
|
const log = logger("auth");
|
|
@@ -271,9 +271,19 @@ export function registerAuthRoutes(app, store) {
|
|
|
271
271
|
}
|
|
272
272
|
failures.delete(client);
|
|
273
273
|
store.setPassword(next);
|
|
274
|
-
// The rotation just killed this caller's
|
|
275
|
-
//
|
|
276
|
-
|
|
274
|
+
// The rotation just killed every cookie out there, this caller's included —
|
|
275
|
+
// a password is changed because the old one may be known, and "everyone
|
|
276
|
+
// signs in again" is the whole point. Clear the dead cookie; the client
|
|
277
|
+
// sends the person to the login form with the password they just chose.
|
|
278
|
+
deleteCookie(c, COOKIE, { path: "/" });
|
|
279
|
+
return c.json({ ok: true });
|
|
280
|
+
});
|
|
281
|
+
// Signs out this browser by clearing its cookie. The value itself stays
|
|
282
|
+
// verifiable until it expires — it is a signature, not a stored id — so the
|
|
283
|
+
// full revocation story remains the password change above. Behind the
|
|
284
|
+
// boundary like every write: only a signed-in browser has anything to end.
|
|
285
|
+
app.post("/logout", (c) => {
|
|
286
|
+
deleteCookie(c, COOKIE, { path: "/" });
|
|
277
287
|
return c.json({ ok: true });
|
|
278
288
|
});
|
|
279
289
|
}
|
package/dist/web/explorer.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Files view backend: read-only directory listing, file bytes and git
|
|
2
|
-
// ref/diff queries for the Console's Files view.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// ref/diff queries for the Console's Files view. `root` is any directory the
|
|
3
|
+
// process can read — sessions work in worktrees and siblings of their cwd, and
|
|
4
|
+
// an owner who is already past the Console password can reach those paths
|
|
5
|
+
// anyway. `path` is still confined to the `root` it was asked under, so a
|
|
6
|
+
// listing can never widen itself, and nothing here writes.
|
|
5
7
|
import { execFile } from "node:child_process";
|
|
6
8
|
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
7
9
|
import { basename, extname, isAbsolute, resolve, sep } from "node:path";
|
|
@@ -27,17 +29,16 @@ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
|
|
|
27
29
|
const REF_RE = /^[^-\s][^\s]*$/;
|
|
28
30
|
/** git in `root`, output capped — a diff is display data, not an archive. */
|
|
29
31
|
const git = async (root, ...args) => (await run("git", ["-C", root, ...args], { maxBuffer: MAX_DIFF_BYTES })).stdout;
|
|
30
|
-
export function registerExplorerRoutes(app
|
|
31
|
-
/** The scope check every route shares: `root` must be
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
export function registerExplorerRoutes(app) {
|
|
33
|
+
/** The scope check every route shares: `root` must be an absolute directory,
|
|
34
|
+
* and `path` must resolve inside it (realpath both ends — neither `..` nor a
|
|
35
|
+
* symlink steps outside). Returns the real target. */
|
|
34
36
|
const resolveScoped = async (root, path = "") => {
|
|
35
37
|
if (!root || !isAbsolute(root))
|
|
36
|
-
throw new Error("root must be
|
|
37
|
-
const known = new Set([...(await factory.list()).map((s) => s.cwd), ...nascentCwds()]);
|
|
38
|
-
if (!known.has(root))
|
|
39
|
-
throw new Error("root must be a known project directory");
|
|
38
|
+
throw new Error("root must be an absolute directory");
|
|
40
39
|
const real = await realpath(root);
|
|
40
|
+
if (!(await stat(real)).isDirectory())
|
|
41
|
+
throw new Error("root must be an absolute directory");
|
|
41
42
|
const target = await realpath(resolve(real, path));
|
|
42
43
|
if (target !== real && !target.startsWith(real + sep))
|
|
43
44
|
throw new Error("path escapes root");
|
|
@@ -91,13 +92,13 @@ export function registerExplorerRoutes(app, { factory, nascentCwds }) {
|
|
|
91
92
|
return { name: line.slice(0, tab), subject: line.slice(tab + 1) };
|
|
92
93
|
});
|
|
93
94
|
// Unit/record separators, because a body is multi-line by nature.
|
|
94
|
-
const commits = (await git(root, "log", "-20", "--format=%h\u001f%at\u001f%an\u001f%s\u001f%b\u001e"))
|
|
95
|
+
const commits = (await git(root, "log", "-20", "--format=%h\u001f%at\u001f%an\u001f%ae\u001f%s\u001f%b\u001e"))
|
|
95
96
|
.split("\u001e")
|
|
96
97
|
.map((r) => r.trimStart())
|
|
97
98
|
.filter(Boolean)
|
|
98
99
|
.map((r) => {
|
|
99
|
-
const [hash = "", at = "", author = "", subject = "", body = ""] = r.split("\u001f");
|
|
100
|
-
return { hash, at: Number(at) * 1000, author, subject, body: body.trim() };
|
|
100
|
+
const [hash = "", at = "", author = "", email = "", subject = "", body = ""] = r.split("\u001f");
|
|
101
|
+
return { hash, at: Number(at) * 1000, author, email, subject, body: body.trim() };
|
|
101
102
|
});
|
|
102
103
|
return c.json({ branch, refs, commits });
|
|
103
104
|
});
|
package/dist/web/files.js
CHANGED
|
@@ -39,7 +39,7 @@ export function guarded(app, method, path, status, fn) {
|
|
|
39
39
|
}
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
|
-
export function registerFileRoutes(app, { factory, config, nascentCwd }) {
|
|
42
|
+
export function registerFileRoutes(app, { factory, config, nascentCwd, onConfigWritten }) {
|
|
43
43
|
// Scope comes from the client as "global" or a project cwd; only cwds Pi
|
|
44
44
|
// already knows (the session list) are accepted — never an arbitrary path.
|
|
45
45
|
const parseScope = async (raw) => {
|
|
@@ -77,6 +77,7 @@ export function registerFileRoutes(app, { factory, config, nascentCwd }) {
|
|
|
77
77
|
}
|
|
78
78
|
const name = c.req.param("name");
|
|
79
79
|
await config.writeFile(scope, name, body.content, body.expected);
|
|
80
|
+
onConfigWritten?.();
|
|
80
81
|
return c.json({ ok: true, content: await config.readFile(scope, name) });
|
|
81
82
|
});
|
|
82
83
|
// Resource names may contain slashes — query params, not path params.
|
package/dist/web/instance.js
CHANGED
|
@@ -7,7 +7,12 @@ import { normalizeModelMenu, normalizePublicUrl } from "../settings.js";
|
|
|
7
7
|
* a loop, and the journal is shared with everything else Pier says. */
|
|
8
8
|
const CLIENT_LOG_PER_MINUTE = 60;
|
|
9
9
|
export function registerInstanceRoutes(app, deps) {
|
|
10
|
-
const { settings, updates, secrets, onUnlocked } = deps;
|
|
10
|
+
const { settings, updates, updater = null, secrets, onUnlocked, onSettingsChanged } = deps;
|
|
11
|
+
const updateLog = logger("update");
|
|
12
|
+
// How long POST /api/update may hold its response open. A busy Pier drains
|
|
13
|
+
// first, which can take minutes, and a response held that long dies at every
|
|
14
|
+
// proxy on the way (principle 7): past this cap the answer is "draining".
|
|
15
|
+
const APPLY_REPLY_CAP_MS = 10_000;
|
|
11
16
|
// The browser's half of the log. A workbench that threw after the response
|
|
12
17
|
// left the server is otherwise invisible here (ui/report.ts) — this is the
|
|
13
18
|
// one route whose entire purpose is to make it visible.
|
|
@@ -35,16 +40,69 @@ export function registerInstanceRoutes(app, deps) {
|
|
|
35
40
|
// Instance settings. The password lives behind its own route (web/auth.ts):
|
|
36
41
|
// it is a credential, and changing it takes the old one.
|
|
37
42
|
app.get("/api/settings", (c) => c.json(settings.get()));
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
43
|
+
// What the version badge reads: the two versions, whether this instance can
|
|
44
|
+
// do anything about the gap, and whether it is allowed to do it unattended.
|
|
45
|
+
// `statusNow` so a browser opened seconds after a restart is told the truth
|
|
46
|
+
// rather than "no idea yet".
|
|
47
|
+
app.get("/api/update", async (c) => c.json({
|
|
48
|
+
...(await updates.statusNow()),
|
|
49
|
+
canApply: updater !== null,
|
|
50
|
+
autoUpdate: settings.get().autoUpdate,
|
|
51
|
+
// Reported whether or not an update is pending: the repair is the same,
|
|
52
|
+
// and finding out at the next restart is finding out too late.
|
|
53
|
+
problem: updater?.problem() ?? null,
|
|
54
|
+
}));
|
|
55
|
+
// Applying. Nothing is installed here: the work is handed to the service
|
|
56
|
+
// manager's own oneshot unit, which stops Pier, backs the database up,
|
|
57
|
+
// installs and starts Pier again — an npm child of this process would be
|
|
58
|
+
// killed by the very restart it is performing.
|
|
59
|
+
app.post("/api/update", async (c) => {
|
|
60
|
+
if (!updater) {
|
|
61
|
+
return c.json({ error: "no service manager owns this Pier — update it with: pier update" }, 409);
|
|
62
|
+
}
|
|
63
|
+
const { current, latest, available } = await updates.statusNow();
|
|
64
|
+
if (!available) {
|
|
65
|
+
return c.json({ error: latest === null ? "the registry could not be reached" : `${current} is the latest` }, 409);
|
|
66
|
+
}
|
|
67
|
+
const problem = updater.problem();
|
|
68
|
+
if (problem !== null) {
|
|
69
|
+
updateLog.error(`update to ${latest} refused: ${problem}`);
|
|
70
|
+
return c.json({ error: problem }, 409);
|
|
71
|
+
}
|
|
72
|
+
const applied = updater.apply().catch((err) => {
|
|
73
|
+
updateLog.error("update handover failed", err);
|
|
74
|
+
return "failed";
|
|
75
|
+
});
|
|
76
|
+
const started = await Promise.race([
|
|
77
|
+
applied,
|
|
78
|
+
new Promise((resolve) => setTimeout(resolve, APPLY_REPLY_CAP_MS, "draining").unref()),
|
|
79
|
+
]);
|
|
80
|
+
if (started === "draining") {
|
|
81
|
+
// The handover keeps running behind this response; if it fails later,
|
|
82
|
+
// main.ts's takeWorkAgain reports it and reopens the gate (§5b).
|
|
83
|
+
updateLog.info(`updating to ${latest} on the Console's request — waiting for running work to finish`);
|
|
84
|
+
return c.json({ started: true, draining: true, latest }, 202);
|
|
85
|
+
}
|
|
86
|
+
if (started === "busy") {
|
|
87
|
+
return c.json({ error: "an update or restart is already in progress" }, 409);
|
|
88
|
+
}
|
|
89
|
+
if (started !== "started") {
|
|
90
|
+
updateLog.error(`update to ${latest} refused by the updater: ${started}`);
|
|
91
|
+
return c.json({
|
|
92
|
+
error: started === "not-installed"
|
|
93
|
+
? "the systemd unit is not installed — run: pier service install"
|
|
94
|
+
: "the updater could not be started; see the journal",
|
|
95
|
+
}, 500);
|
|
96
|
+
}
|
|
97
|
+
updateLog.info(`updating to ${latest} on the Console's request — Pier stops and starts again`);
|
|
98
|
+
return c.json({ started: true, latest });
|
|
99
|
+
});
|
|
42
100
|
// Partial on purpose: each surface sends only the setting it edits, and a
|
|
43
101
|
// malformed field is rejected before anything is written.
|
|
44
102
|
app.put("/api/settings", async (c) => {
|
|
45
103
|
const body = await c.req.json().catch(() => null);
|
|
46
|
-
if (!body || (body.publicUrl === undefined && body.modelMenu === undefined)) {
|
|
47
|
-
return c.json({ error: "publicUrl or
|
|
104
|
+
if (!body || (body.publicUrl === undefined && body.modelMenu === undefined && body.autoUpdate === undefined)) {
|
|
105
|
+
return c.json({ error: "publicUrl, modelMenu or autoUpdate required" }, 400);
|
|
48
106
|
}
|
|
49
107
|
if (body.publicUrl !== undefined) {
|
|
50
108
|
if (typeof body.publicUrl !== "string")
|
|
@@ -62,6 +120,14 @@ export function registerInstanceRoutes(app, deps) {
|
|
|
62
120
|
}
|
|
63
121
|
settings.setModelMenu(menu);
|
|
64
122
|
}
|
|
123
|
+
if (body.autoUpdate !== undefined) {
|
|
124
|
+
if (typeof body.autoUpdate !== "boolean")
|
|
125
|
+
return c.json({ error: "autoUpdate must be a boolean" }, 400);
|
|
126
|
+
settings.setAutoUpdate(body.autoUpdate);
|
|
127
|
+
}
|
|
128
|
+
// Only the URL: the model menu is read per picker call, not per session.
|
|
129
|
+
if (body.publicUrl !== undefined)
|
|
130
|
+
onSettingsChanged?.();
|
|
65
131
|
return c.json(settings.get());
|
|
66
132
|
});
|
|
67
133
|
// Layer-1 key status and control (Console → Settings → Security). The GET
|
package/dist/web/providers.js
CHANGED
|
@@ -37,7 +37,11 @@ function setupFrom(raw) {
|
|
|
37
37
|
models,
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
-
export function registerProviderRoutes(app, providers
|
|
40
|
+
export function registerProviderRoutes(app, providers,
|
|
41
|
+
/** A session picks its providers up when it opens, so one that is already
|
|
42
|
+
* live cannot use what was just configured: server.ts recycles the idle
|
|
43
|
+
* ones after every change of credentials or structure. */
|
|
44
|
+
onProvidersChanged = () => { }) {
|
|
41
45
|
const flows = new ProviderFlows(providers);
|
|
42
46
|
const configure = async (setup) => {
|
|
43
47
|
await providers.setup(setup);
|
|
@@ -67,8 +71,11 @@ export function registerProviderRoutes(app, providers) {
|
|
|
67
71
|
if (setup.kind === "custom" && authType === "oauth") {
|
|
68
72
|
return c.json({ error: "custom providers support API-key authentication" }, 400);
|
|
69
73
|
}
|
|
70
|
-
if (authType === null)
|
|
71
|
-
|
|
74
|
+
if (authType === null) {
|
|
75
|
+
const provider = await configure(setup);
|
|
76
|
+
onProvidersChanged();
|
|
77
|
+
return c.json({ ok: true, provider });
|
|
78
|
+
}
|
|
72
79
|
const before = (await providers.providers()).find((candidate) => candidate.id === setup.id);
|
|
73
80
|
if (setup.kind === "builtin" && !before?.builtin) {
|
|
74
81
|
return c.json({ error: "unknown built-in provider" }, 400);
|
|
@@ -78,9 +85,13 @@ export function registerProviderRoutes(app, providers) {
|
|
|
78
85
|
const flow = await flows.start(setup.id, authType, async () => {
|
|
79
86
|
if (!before)
|
|
80
87
|
requireMethod(await configure(setup), authType);
|
|
81
|
-
},
|
|
88
|
+
},
|
|
89
|
+
// Runs once the login itself succeeded: the credential is new even
|
|
90
|
+
// where the structure was already written by `prepare`.
|
|
91
|
+
async () => {
|
|
82
92
|
if (before)
|
|
83
93
|
await configure(setup);
|
|
94
|
+
onProvidersChanged();
|
|
84
95
|
});
|
|
85
96
|
return c.json(flow, 202);
|
|
86
97
|
}
|
|
@@ -120,6 +131,7 @@ export function registerProviderRoutes(app, providers) {
|
|
|
120
131
|
app.post("/api/providers/:provider/logout", async (c) => {
|
|
121
132
|
try {
|
|
122
133
|
await providers.logout(c.req.param("provider"));
|
|
134
|
+
onProvidersChanged();
|
|
123
135
|
return c.json({ ok: true });
|
|
124
136
|
}
|
|
125
137
|
catch (err) {
|