@vincemakes/kiso-web 0.0.0-stage → 0.52.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kiso contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # kiso web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local web app on the kiso hosted-session service: `kiso-web` runs the host in its own process and serves the page at
4
+ `http://127.0.0.1:4317`.
5
+
6
+ ```bash
7
+ npm install -g @vincemakes/kiso-web
8
+ kiso web # or kiso-web; the terminal's `kiso` runs this package when it is installed
9
+ ```
10
+
11
+ The host must be running for the page to work: it runs while the command runs. Node 22 or later, macOS or Linux (Windows is not supported yet); the
12
+ page runs in Safari 17, Chrome or Edge. Built into the root build; typecheck, gates and proofs run here and in the Web workflow.
13
+
14
+ Development:
15
+
16
+ ```
17
+ npm run build -w @vincemakes/kiso-web # tsc (host, shared, state) + vite (the page into out/)
18
+ npm run start -w @vincemakes/kiso-web # kiso-web: the host, and the default browser opened on the page
19
+ npm run gates -w @vincemakes/kiso-web # size, deps, imports, names, paths, CJK
20
+ npm run proofs:fast -w @vincemakes/kiso-web # daily, before each commit: no browser, no kill -9
21
+ npm run proofs:full -w @vincemakes/kiso-web # end of a milestone, before a push, before a release
22
+ npm run check -w @vincemakes/kiso-web # build + typecheck + gates + proofs:fast
23
+ npm run check:full -w @vincemakes/kiso-web # the same with proofs:full
24
+ ```
25
+
26
+ `kiso-web [--port 4317] [--no-open] [--revoke-sessions]`. The port is fixed, so a page added to the Dock (Safari: File > Add to Dock;
27
+ Chrome or Edge: Install page as app) keeps its address. Running it again while a host holds the port opens the browser on that host;
28
+ anything else on the port is an error that names the process. The host runs while the command runs: with no host, the page cannot load.
29
+
30
+ Sign-in: the command opens the browser on a one-time connect code in the address (good for two minutes); the page trades it for a
31
+ session cookie (`HttpOnly`, `SameSite=Strict`, named after the port) and strips it from the address, so the page holds no secret of its
32
+ own. The host keeps the sessions hashed in `$KISO_HOME/web.json` (mode 0600); `--revoke-sessions` forgets them all, and every page signs in
33
+ again from the command. Every request still passes the Host and Origin checks: a cookie on 127.0.0.1 is sent to every port of it. A session
34
+ is a key to this machine's shell: do not run a page you do not trust on 127.0.0.1.
35
+
36
+ Each proof is a script; its exit code is the verdict. `fast` is the reducer, markdown, usage, guard, sign-in, policy, control and task
37
+ proofs (seconds to under a minute). `full` adds the ones that kill the host with `kill -9` and the page proofs, which drive a headless
38
+ Chrome through the DevTools protocol (Chrome is found on the machine, or named by `KISO_WEB_CHROME`), including a tab closed and reopened
39
+ mid-run and one approval answered from two tabs. Rule: a change that only touches page styles or components needs `fast`; a change to
40
+ `src/main`, `src/shared`, the state reducer, the transport or recovery needs `full`.
41
+
42
+ The model, the effort and the sign-in come from the terminal's own configuration (`~/.kiso/config.json`, `auth.json`), read through
43
+ the `./config` door of `@vincemakes/kiso-code`: one login serves both products, and no key is read from the web app's own environment.
44
+ Works live where the terminal keeps sessions (`~/.kiso/projects/<enc>/`), so the history lists the Works of every folder. The folder
45
+ a new task starts in is `KISO_WEB_WORKSPACE`, else the one used last while it still exists, else the home folder; another is chosen in
46
+ the page's folder browser (folders only; from home, or from a full path typed in). Notifications are off until turned on in Settings,
47
+ and only while the page is open. See `src/main/env.ts` for the rest (`KISO_WEB_FAUX` runs a scripted model, for the proofs).
48
+
49
+ The proofs budget (`scripts/proofs`) is 2,100 lines from W1c on, by an owner ruling of 2026-11-01 (it was 1,800 from W1b by a ruling of 2026-10-31, 1,600 from S5 by a ruling of 2026-10-26, 1,300 from S4 by a ruling of 2026-10-25, 1,000 from S2 by a ruling of 2026-10-23, and 700 before); the other budgets are unchanged.
50
+
51
+ Plans, mocks and screenshots live outside this repository.
@@ -0,0 +1,78 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { Agent, createAgent, kisoHome, runsACheck } from "@vincemakes/kiso-runtime";
4
+ import { adapterOptionsFor } from "@vincemakes/kiso-code/config";
5
+ import { createCodingTools } from "@vincemakes/kiso-tools-node";
6
+ import { CODING_TOOL_RULES, breakerExtension, composeSystemPrompt, floorExtension, modeExtensions, modeSystemPrompt, protectedShellExtension, readOnlyShellExtension, setMode, } from "@vincemakes/kiso-code/policy";
7
+ import { observedRevisionFrom } from "./observed-revision.js";
8
+ /** The adapter identity a model is routed by (what pricing is looked up on). */
9
+ export const routeOf = (model) => (model.kind === "adapter" ? model.route : model.profile.kind);
10
+ const DEFAULT_WINDOW = 200_000;
11
+ /** The credential store is never served to a model: file tools and shell checks share this list. */
12
+ export function protectedFiles() {
13
+ return [join(kisoHome(), "auth.json")];
14
+ }
15
+ /** The agent subclass the service opens per session: it binds the session's
16
+ * task delivery after the runtime creates the session — the service gives
17
+ * a host no other hook on the Session object. */
18
+ class WebAgent extends Agent {
19
+ #options;
20
+ constructor(definition, options) {
21
+ super(definition);
22
+ this.#options = options;
23
+ }
24
+ async session(options) {
25
+ const session = await super.session(options);
26
+ this.#options.onSession?.(options.id, session);
27
+ return session;
28
+ }
29
+ }
30
+ export function createWebAgent(options) {
31
+ const { store, workspace, model } = options;
32
+ const root = () => workspace;
33
+ const toolOptions = {
34
+ workspaceRoot: workspace,
35
+ excludeRoots: [kisoHome()],
36
+ secretEnvNames: options.secretEnvNames ?? [],
37
+ protectedFiles: protectedFiles(),
38
+ tasks: options.tasks.shellTasks,
39
+ observedRevision: observedRevisionFrom(store, workspace),
40
+ ...(options.toolLimits !== undefined ? { limits: options.toolLimits } : {}),
41
+ };
42
+ const window = model.kind === "profile" ? model.contextWindow : undefined;
43
+ const prompt = composeSystemPrompt(workspace, protectedFiles());
44
+ const modeNote = modeSystemPrompt(options.tier());
45
+ const definition = {
46
+ model: model.kind === "adapter" ? model.model : model.profile.model,
47
+ store,
48
+ workspace,
49
+ systemPrompt: modeNote === undefined ? prompt : `${prompt}\n\n${modeNote}`,
50
+ toolRules: CODING_TOOL_RULES,
51
+ tools: [...createCodingTools(toolOptions)],
52
+ // the terminal's chain, in its order: the guards that deny speak first
53
+ extensions: [
54
+ protectedShellExtension({ files: protectedFiles, workspaceRoot: root, env: () => ({ home: homedir(), kisoHome: kisoHome() }) }),
55
+ floorExtension(() => true, root),
56
+ breakerExtension(),
57
+ ...modeExtensions(root, options.tier),
58
+ readOnlyShellExtension(() => ({ workspaceRoot: workspace, excludeRoots: toolOptions.excludeRoots })),
59
+ ],
60
+ // in-run compaction, as the terminal turns it on (ADR-0055): only a window someone stated arms the overflow belt
61
+ contextPolicy: {
62
+ tiers: {
63
+ windowTokens: window ?? DEFAULT_WINDOW,
64
+ isCheck: (command) => runsACheck(command, options.checkRunners ?? []),
65
+ statedWindow: () => window ?? null,
66
+ },
67
+ },
68
+ ...(model.kind === "adapter"
69
+ ? { adapter: model.adapter }
70
+ : {
71
+ provider: model.profile.kind,
72
+ ...adapterOptionsFor(model.profile, model.auth, options.sessionId),
73
+ ...(model.profile.streamIdleMs !== undefined ? { streamIdleMs: model.profile.streamIdleMs } : {}),
74
+ ...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
75
+ }),
76
+ };
77
+ return options.onSession === undefined ? createAgent(definition) : new WebAgent(definition, options);
78
+ }
@@ -0,0 +1,68 @@
1
+ const FRESH_MS = 15_000;
2
+ const KEEP = 50;
3
+ export class ApprovalWatch {
4
+ #spaces;
5
+ #notify;
6
+ #presence;
7
+ /** decisions already announced, per `key/session`; forgotten once answered */
8
+ #told = new Map();
9
+ /** Works seen running at the last look */
10
+ #running = new Set();
11
+ #notices = [];
12
+ #seq = 0;
13
+ #timer;
14
+ constructor(spaces, notify, presence) {
15
+ this.#spaces = spaces;
16
+ this.#notify = notify;
17
+ this.#presence = presence;
18
+ }
19
+ start(everyMs = 1_000) {
20
+ this.#timer = setInterval(() => void this.poll(), everyMs);
21
+ this.#timer.unref();
22
+ }
23
+ stop() {
24
+ clearInterval(this.#timer);
25
+ }
26
+ /** The notices after `seq` (the page keeps the last number it saw), and the newest number. */
27
+ since(seq) {
28
+ return { seq: this.#seq, notices: this.#notices.filter((n) => n.seq > seq) };
29
+ }
30
+ #announce(notice) {
31
+ const full = { ...notice, seq: ++this.#seq };
32
+ this.#notices.push(full);
33
+ if (this.#notices.length > KEEP)
34
+ this.#notices.shift();
35
+ this.#notify?.(full);
36
+ }
37
+ async poll() {
38
+ const p = this.#presence();
39
+ const here = p !== null && p.focused && Date.now() - p.at < FRESH_MS ? p : null;
40
+ for (const space of this.#spaces()) {
41
+ for (const sessionId of space.opened()) {
42
+ const id = `${space.key}/${sessionId}`;
43
+ const away = here === null || here.key !== space.key || here.session !== sessionId;
44
+ const folder = space.workspace.split("/").pop() ?? "";
45
+ const running = space.service.isRunning(sessionId);
46
+ if (running)
47
+ this.#running.add(id);
48
+ else if (this.#running.delete(id) && away)
49
+ this.#announce({ kind: "done", key: space.key, sessionId, folder });
50
+ const pending = running ? await space.service.pendingApprovals(sessionId).catch(() => []) : [];
51
+ const told = this.#told.get(id) ?? new Set();
52
+ const still = new Set();
53
+ for (const approval of pending) {
54
+ if (told.has(approval.decisionId))
55
+ still.add(approval.decisionId);
56
+ else if (away) {
57
+ still.add(approval.decisionId);
58
+ this.#announce({ kind: "approval", key: space.key, sessionId, decisionId: approval.decisionId, tool: approval.name, folder });
59
+ }
60
+ }
61
+ if (still.size === 0)
62
+ this.#told.delete(id);
63
+ else
64
+ this.#told.set(id, still);
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,112 @@
1
+ import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
2
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { resolveInside } from "./files.js";
4
+ /**
5
+ * What a tool call made that the page can show, each "best effort, never a guess": an image block of its result; an artifact
6
+ * file a successful write or edit touched; or, for a command, a path its text or its output head names that is inside the
7
+ * folder, of an artifact type, exists, and changed at or after the step began. No folder scan, no watcher.
8
+ */
9
+ const KINDS = {
10
+ ".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image",
11
+ ".html": "page", ".htm": "page", ".md": "doc", ".pdf": "doc", ".csv": "doc",
12
+ ".mp4": "media", ".webm": "media", ".mov": "media", ".mp3": "media", ".wav": "media",
13
+ };
14
+ const NAMED = /[\w@+./-]+\.(?:png|jpe?g|gif|webp|svg|html?|md|pdf|csv|mp4|webm|mov|mp3|wav)\b/gi;
15
+ /** a file's clock and the log's may differ by the file system's granularity */
16
+ const SLACK_MS = 50;
17
+ export const artifactKind = (path) => KINDS[extname(path).toLowerCase()];
18
+ /** `root`: the folder's real path. */
19
+ export function artifactsOf(event, workId, call, root) {
20
+ const out = [];
21
+ const ref = (n, kind, uri, mediaType) => {
22
+ out.push({ id: `${event.callId}:${n}`, workId, callId: event.callId, kind, uri, ...(mediaType !== undefined ? { mediaType } : {}) });
23
+ };
24
+ if (Array.isArray(event.content))
25
+ event.content.forEach((b, n) => {
26
+ if (b.type === "image" && ((b.sourceType === "base64" && b.data !== undefined) || b.url?.startsWith("https:") === true))
27
+ ref(n, "image", `log:${event.seq}/${n}`, b.mediaType);
28
+ });
29
+ if (event.isError || call === null)
30
+ return out;
31
+ const file = (raw, since) => {
32
+ const rel = relative(root, isAbsolute(raw) ? raw : resolve(root, raw));
33
+ const kind = artifactKind(rel);
34
+ if (kind === undefined || rel.startsWith("..") || out.some((a) => a.uri === `file:${rel}`))
35
+ return;
36
+ try {
37
+ const stat = statSync(resolveInside(root, rel));
38
+ if (stat.isFile() && (since === null || stat.mtimeMs >= since - SLACK_MS))
39
+ ref(rel, kind, `file:${rel}`);
40
+ }
41
+ catch {
42
+ // gone, outside, protected: not shown
43
+ }
44
+ };
45
+ const path = call.input["path"];
46
+ if ((call.name === "write_file" || call.name === "edit_file") && typeof path === "string")
47
+ file(path, null);
48
+ if (call.name === "shell" && call.startedAt !== null) {
49
+ const text = `${String(call.input["command"] ?? "")}\n${typeof event.content === "string" ? event.content.slice(0, 16_384) : ""}`;
50
+ for (const m of text.matchAll(NAMED))
51
+ file(m[0], call.startedAt);
52
+ }
53
+ return out;
54
+ }
55
+ /**
56
+ * Image blocks of results, read from the session's log: an index of seq to the byte range of its line, built once per log and
57
+ * extended as it grows, so a request reads one event, never the whole log.
58
+ */
59
+ export class LogImages {
60
+ #dir;
61
+ #logs = new Map();
62
+ constructor(dir) {
63
+ this.#dir = dir;
64
+ }
65
+ /** The n-th block of the result at `seq`: its bytes, an https address to pass on, or null. */
66
+ block(sessionId, seq, n) {
67
+ const file = join(this.#dir, `${sessionId}.jsonl`);
68
+ const at = this.#index(file).get(seq);
69
+ if (at === undefined)
70
+ return null;
71
+ const event = JSON.parse(read(file, at[0], at[1]).toString("utf8")).event;
72
+ const b = event.type === "tool_result" && Array.isArray(event.content) ? event.content[n] : undefined;
73
+ if (b?.type !== "image")
74
+ return null;
75
+ if (b.sourceType === "base64" && b.data !== undefined)
76
+ return { type: b.mediaType ?? "image/png", bytes: Buffer.from(b.data, "base64") };
77
+ return b.url?.startsWith("https:") === true ? { url: b.url } : null;
78
+ }
79
+ #index(file) {
80
+ let log = this.#logs.get(file);
81
+ const size = statSync(file).size;
82
+ if (log === undefined || size < log.size)
83
+ this.#logs.set(file, (log = { size: 0, lines: new Map() }));
84
+ if (size > log.size) {
85
+ const chunk = read(file, log.size, size - log.size);
86
+ let from = 0;
87
+ for (let nl = chunk.indexOf(10); nl !== -1; from = nl + 1, nl = chunk.indexOf(10, from)) {
88
+ try {
89
+ const seq = JSON.parse(chunk.subarray(from, nl).toString("utf8")).event.seq;
90
+ log.lines.set(seq, [log.size + from, nl - from]);
91
+ }
92
+ catch {
93
+ // a line that is not a record: skipped
94
+ }
95
+ }
96
+ // a line still being written is read next time
97
+ log.size += from;
98
+ }
99
+ return log.lines;
100
+ }
101
+ }
102
+ function read(file, start, length) {
103
+ const fd = openSync(file, "r");
104
+ try {
105
+ const buf = Buffer.alloc(Math.max(0, Math.min(length, fstatSync(fd).size - start)));
106
+ readSync(fd, buf, 0, buf.length, start);
107
+ return buf;
108
+ }
109
+ finally {
110
+ closeSync(fd);
111
+ }
112
+ }
@@ -0,0 +1,109 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * The page's sign-in. The command that starts the host opens the browser on `#c=<code>`: a one-time connect code that lives two
6
+ * minutes. The page trades it once (`POST /auth/connect`) for a session cookie, `HttpOnly` and `SameSite=Strict`, named after the
7
+ * port, and strips the fragment: the page never holds a long-lived secret, and a cookie (unlike page storage) is what Safari copies
8
+ * into a web app added to the Dock. The host keeps the sessions in `$KISO_HOME/web.json` (mode 0600), as SHA-256 hashes only, and
9
+ * reads the file again when it changes, so `kiso-web --revoke-sessions` reaches a host that is running. A cookie on 127.0.0.1 is
10
+ * sent to every port of that host, so the cookie is never the whole guard: the Host and Origin checks stay on every request.
11
+ */
12
+ const CODE_TTL_MS = 120_000;
13
+ /** a year: the cookie outlives a browser restart and a Dock launch (browsers cap it near 400 days anyway) */
14
+ const COOKIE_AGE_S = 365 * 86_400;
15
+ const MAX_SESSIONS = 50;
16
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
17
+ export const cookieName = (port) => `kiso_web_${port}`;
18
+ export const webFile = (home) => join(home, "web.json");
19
+ function readWeb(file) {
20
+ try {
21
+ return JSON.parse(readFileSync(file, "utf8"));
22
+ }
23
+ catch {
24
+ return {};
25
+ }
26
+ }
27
+ function writeWeb(file, data) {
28
+ mkdirSync(dirname(file), { recursive: true });
29
+ const tmp = `${file}.${process.pid}.tmp`;
30
+ writeFileSync(tmp, `${JSON.stringify(data, null, "\t")}\n`, { mode: 0o600 });
31
+ chmodSync(tmp, 0o600);
32
+ renameSync(tmp, file);
33
+ }
34
+ /** Forget every session: each page signed in before must be opened again from the command. Returns how many were forgotten. */
35
+ export function revokeSessions(home) {
36
+ const file = webFile(home);
37
+ const count = readWeb(file).sessions?.length ?? 0;
38
+ writeWeb(file, { ...readWeb(file), sessions: [] });
39
+ return count;
40
+ }
41
+ export class Auth {
42
+ #file;
43
+ #cookie;
44
+ #codes = new Map();
45
+ #hashes = new Set();
46
+ #stamp = "";
47
+ constructor(home, port) {
48
+ this.#file = webFile(home);
49
+ this.#cookie = cookieName(port);
50
+ }
51
+ /** A fresh one-time code for the browser's address. */
52
+ code() {
53
+ const now = Date.now();
54
+ for (const [c, at] of this.#codes)
55
+ if (now - at > CODE_TTL_MS)
56
+ this.#codes.delete(c);
57
+ const code = randomBytes(24).toString("base64url");
58
+ this.#codes.set(sha256(code), now);
59
+ return code;
60
+ }
61
+ /** Trade a code for a new session: the Set-Cookie value, or null (unknown, used or expired). */
62
+ connect(code) {
63
+ if (typeof code !== "string" || code.length > 128)
64
+ return null;
65
+ const key = sha256(code);
66
+ const at = this.#codes.get(key);
67
+ this.#codes.delete(key);
68
+ if (at === undefined || Date.now() - at > CODE_TTL_MS)
69
+ return null;
70
+ const session = randomBytes(32).toString("base64url");
71
+ const kept = (readWeb(this.#file).sessions ?? []).slice(-(MAX_SESSIONS - 1));
72
+ writeWeb(this.#file, { ...readWeb(this.#file), sessions: [...kept, { hash: sha256(session), createdAt: Date.now() }] });
73
+ return `${this.#cookie}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_AGE_S}`;
74
+ }
75
+ /** Whether the request carries a session cookie the host still keeps. */
76
+ signedIn(req) {
77
+ const given = (req.headers.cookie ?? "")
78
+ .split(";")
79
+ .map((part) => part.trim())
80
+ .find((part) => part.startsWith(`${this.#cookie}=`))
81
+ ?.slice(this.#cookie.length + 1);
82
+ if (given === undefined || given === "")
83
+ return false;
84
+ this.#reload();
85
+ const hash = Buffer.from(sha256(given));
86
+ for (const known of this.#hashes)
87
+ if (timingSafeEqual(Buffer.from(known), hash))
88
+ return true;
89
+ return false;
90
+ }
91
+ /** The cookie that ends this browser's session (a sign-out). */
92
+ cleared() {
93
+ return `${this.#cookie}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
94
+ }
95
+ #reload() {
96
+ let stamp = "none";
97
+ try {
98
+ const s = statSync(this.#file);
99
+ stamp = `${s.mtimeMs}:${s.size}`;
100
+ }
101
+ catch {
102
+ // no file: no session
103
+ }
104
+ if (stamp === this.#stamp)
105
+ return;
106
+ this.#stamp = stamp;
107
+ this.#hashes = new Set((readWeb(this.#file).sessions ?? []).map((s) => s.hash));
108
+ }
109
+ }
@@ -0,0 +1,130 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { canonicalPath, claimProjectDir, projectDirFor } from "@vincemakes/kiso-code/config";
4
+ import { deskRead } from "./fs-routes.js";
5
+ import { existing, Refusal, resolveInside } from "./files.js";
6
+ import { artifactKind } from "./artifacts.js";
7
+ import { listDirs, validateFolder } from "./folders.js";
8
+ import { folderInfo } from "./folder-info.js";
9
+ const json = (status, body) => ({ status, body });
10
+ const BODY_CAP = 16 * 1024;
11
+ async function readJson(req) {
12
+ let text = "";
13
+ for await (const chunk of req) {
14
+ text += chunk;
15
+ if (text.length > BODY_CAP)
16
+ throw new Refusal(413, "too large");
17
+ }
18
+ try {
19
+ const parsed = JSON.parse(text);
20
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
21
+ return parsed;
22
+ }
23
+ catch {
24
+ // fall through
25
+ }
26
+ throw new Refusal(400, "a JSON object is expected");
27
+ }
28
+ const str = (v) => (typeof v === "string" ? v : undefined);
29
+ export async function deskApi(ctx, req, url) {
30
+ const path = url.pathname;
31
+ const where = url.searchParams.get("key");
32
+ const space = where === null || where === "" ? ctx.current : ctx.spaceFor(where);
33
+ const post = req.method === "POST";
34
+ try {
35
+ if (path === "/desk/fs/tree" || path === "/desk/fs/file" || path === "/desk/task-log") {
36
+ if (space === null)
37
+ return json(404, {});
38
+ return req.method === "GET" ? deskRead(url, space) : json(405, {});
39
+ }
40
+ if (path === "/desk/exists") {
41
+ if (space === null)
42
+ return json(404, {});
43
+ return post ? json(200, { found: existing(realpathSync(space.workspace), (await readJson(req))["paths"]) }) : json(405, {});
44
+ }
45
+ // an image block of a result, from the Work's log: its bytes, or an https address passed on
46
+ const image = /^\/desk\/image\/([A-Za-z0-9._-]+)\/(\d+)\/(\d+)$/.exec(path);
47
+ if (image !== null) {
48
+ if (space === null || req.method !== "GET" || !space.store.ids().includes(image[1]))
49
+ return json(404, {});
50
+ const b = space.images.block(image[1], Number(image[2]), Number(image[3]));
51
+ if (b === null)
52
+ return json(404, {});
53
+ return "url" in b ? { status: 302, body: null, headers: { location: b.url } } : { status: 200, body: b.bytes, headers: { "content-type": b.type, "x-content-type-options": "nosniff", "cache-control": "private, max-age=86400" } };
54
+ }
55
+ // a page, a pdf or media of the folder on its own preview server (preview.ts); the address it answers
56
+ if (path === "/desk/preview" && post) {
57
+ if (space === null)
58
+ return json(404, {});
59
+ const entry = resolveInside(realpathSync(space.workspace), String((await readJson(req))["path"] ?? ""));
60
+ if (!statSync(entry).isFile() || artifactKind(entry) === undefined)
61
+ throw new Refusal(400, "not a page, a document or media");
62
+ const host = req.headers.host ?? "";
63
+ return json(200, { url: await ctx.previews.open(entry, host, `http://${host}`) });
64
+ }
65
+ if (path === "/desk/preview-close" && post) {
66
+ ctx.previews.close(String((await readJson(req))["url"] ?? ""));
67
+ return json(200, { ok: true });
68
+ }
69
+ if (path === "/desk/env") {
70
+ if (space === null)
71
+ return json(404, {});
72
+ return req.method === "GET" ? json(200, folderInfo(space.workspace, ctx.settings.tierOf(url.searchParams.get("session") ?? undefined))) : json(405, {});
73
+ }
74
+ if (path === "/desk/history")
75
+ return req.method === "GET" ? json(200, await ctx.history.snapshot({ key: ctx.current.key, dir: ctx.current.dir }, url.searchParams.get("fresh") ?? undefined)) : json(405, {});
76
+ if (path === "/desk/settings") {
77
+ if (post)
78
+ ctx.settings.apply((await readJson(req)));
79
+ else if (req.method !== "GET")
80
+ return json(405, {});
81
+ return json(200, ctx.settings.snapshot(url.searchParams.get("session") ?? undefined));
82
+ }
83
+ if (path === "/desk/folder" && post) {
84
+ const workspace = validateFolder((await readJson(req))["path"]);
85
+ const dir = projectDirFor(ctx.home, workspace);
86
+ claimProjectDir(dir, workspace);
87
+ ctx.settings.addRecent(workspace);
88
+ ctx.settings.write({ lastFolder: workspace });
89
+ const opened = ctx.spaceFor(basename(dir));
90
+ return opened === null ? json(500, {}) : json(200, { key: opened.key, folder: basename(workspace) });
91
+ }
92
+ if (path === "/desk/recent" && req.method === "GET") {
93
+ const rows = ctx.settings.recents().flatMap((p) => {
94
+ try {
95
+ const real = validateFolder(p);
96
+ return [{ folder: basename(real), path: real, key: basename(projectDirFor(ctx.home, canonicalPath(real))) }];
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ });
102
+ return json(200, { recent: rows });
103
+ }
104
+ if (path === "/desk/login" && post) {
105
+ const b = await readJson(req);
106
+ ctx.settings.saveKey(str(b["profile"]) ?? "", b["key"]);
107
+ return json(200, { ok: true });
108
+ }
109
+ if (path === "/desk/demo" && post) {
110
+ await ctx.settings.startDemo();
111
+ return json(200, { ok: true });
112
+ }
113
+ if (path === "/desk/notices" && req.method === "GET")
114
+ return json(200, ctx.notices(Number(url.searchParams.get("after") ?? "0") || 0));
115
+ if (path === "/desk/dirs" && req.method === "GET")
116
+ return json(200, listDirs(url.searchParams.get("root"), url.searchParams.get("path") ?? ""));
117
+ if (path === "/desk/presence" && post) {
118
+ const b = await readJson(req);
119
+ const key = str(b["key"]) ?? "";
120
+ ctx.setPresence({ key: key === "" ? ctx.current.key : key, session: str(b["session"]) ?? "", focused: b["focused"] === true, at: Date.now() });
121
+ return json(200, { ok: true });
122
+ }
123
+ return json(404, {});
124
+ }
125
+ catch (error) {
126
+ if (error instanceof Refusal)
127
+ return json(error.status, { code: "refused", message: error.message });
128
+ return json(500, { code: "internal", message: "failed" });
129
+ }
130
+ }