@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.
@@ -0,0 +1,102 @@
1
+ import { appendFileSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { canonicalPath, projectDirFor } from "@vincemakes/kiso-code/config";
5
+ import { kisoHome } from "@vincemakes/kiso-runtime";
6
+ import { fauxSkip } from "./faux.js";
7
+ import { startHost } from "./host.js";
8
+ import { Settings } from "./settings.js";
9
+ /**
10
+ * The host's configuration from the environment (the `kiso-web` command
11
+ * reads its flags in headless.ts), so a credential never rides argv:
12
+ *
13
+ * KISO_WEB_DATA the kiso home: `projects/<enc>/` under it holds the Works (default: the terminal's own home)
14
+ * KISO_WEB_WORKSPACE the folder a new task starts in (default: the folder used last, else the working directory)
15
+ * KISO_WEB_FAUX path to a JSON FauxScript (the keyless model)
16
+ * KISO_WEB_FAUX_SKIP a session id: skip the script turns its log already consumed
17
+ * KISO_WEB_MODE the permission tier: default | accept-edits | plan | full-access
18
+ * KISO_WEB_NOTIFY_LOG a file: notices (an approval waits, a run ended) are also appended to it, as JSON lines (the proofs)
19
+ * KISO_WEB_REQUEST_LOG, KISO_WEB_PREVIEW_LOG files: each request the host, or a preview server, received (the proofs)
20
+ * KISO_WEB_SANITIZER "default" | "web"
21
+ * KISO_WEB_THINKING 0: leave reasoning text off the wire (default: on)
22
+ * KISO_WEB_KEEPALIVE keepalive period in ms
23
+ * KISO_WEB_MIN_FG_MS the shell's least foreground wait (0 keeps the model's value)
24
+ * KISO_WEB_PORT the port (default 4317; 0: the system picks), and KISO_WEB_TOKEN the scripts' bearer
25
+ * token (default: a fresh one per launch, never printed) — the crash script restarts
26
+ * the host where a live client expects it
27
+ *
28
+ */
29
+ /** A scripted model, for the proofs; otherwise null: the model comes from the shared configuration (settings.ts). */
30
+ async function fauxFromEnv(sessionsDir) {
31
+ const fauxPath = process.env["KISO_WEB_FAUX"];
32
+ if (fauxPath === undefined || fauxPath === "")
33
+ return null;
34
+ const { createFauxProvider } = await import("@vincemakes/kiso-evals");
35
+ const script = JSON.parse(readFileSync(fauxPath, "utf8"));
36
+ const skipFor = process.env["KISO_WEB_FAUX_SKIP"];
37
+ const skip = skipFor === undefined || skipFor === "" ? 0 : fauxSkip(sessionsDir, skipFor);
38
+ return { kind: "adapter", adapter: createFauxProvider(script.slice(skip)), model: "faux", route: "adapter" };
39
+ }
40
+ const MODES = ["default", "accept-edits", "plan", "full-access"];
41
+ function modeFromEnv() {
42
+ const raw = process.env["KISO_WEB_MODE"];
43
+ if (raw === undefined || raw === "")
44
+ return "default";
45
+ const mode = MODES.find((m) => m === raw);
46
+ if (mode === undefined)
47
+ throw new Error(`KISO_WEB_MODE must be one of ${MODES.join(", ")}`);
48
+ return mode;
49
+ }
50
+ const isFolder = (path) => {
51
+ try {
52
+ return statSync(path).isDirectory();
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ };
58
+ /** The folder a new task starts in: named by the environment, else the one used last while it still exists, else where the command was started. */
59
+ function workspaceFor(settings) {
60
+ const named = process.env["KISO_WEB_WORKSPACE"];
61
+ if (named !== undefined && named !== "")
62
+ return named;
63
+ // a remembered folder can be gone (a deleted scratch folder); "/" is never a place to work: both fall back to the home folder
64
+ const last = settings.lastFolder();
65
+ if (last !== undefined && isFolder(last))
66
+ return last;
67
+ const cwd = process.cwd();
68
+ return cwd === "/" || last !== undefined ? homedir() : cwd;
69
+ }
70
+ /** A copy of every notice in a file, for the proofs. */
71
+ function noticeLog() {
72
+ const log = process.env["KISO_WEB_NOTIFY_LOG"];
73
+ return log === undefined || log === "" ? undefined : (n) => appendFileSync(log, `${JSON.stringify(n)}\n`);
74
+ }
75
+ export async function startFromEnv(options = {}) {
76
+ const home = process.env["KISO_WEB_DATA"] ?? kisoHome();
77
+ // the terminal's configuration and sign-in are read from the one root the terminal uses
78
+ process.env["KISO_HOME"] ??= home;
79
+ const keepalive = Number(process.env["KISO_WEB_KEEPALIVE"] ?? "");
80
+ const minFg = Number(process.env["KISO_WEB_MIN_FG_MS"] ?? "");
81
+ // settings first: its saved tier and folder memory are read before anything is opened
82
+ const probe = new Settings(home, null, modeFromEnv());
83
+ const workspace = canonicalPath(workspaceFor(probe));
84
+ const faux = await fauxFromEnv(projectDirFor(home, workspace));
85
+ const settings = faux === null ? probe : new Settings(home, faux, modeFromEnv());
86
+ // the folder used last is only ever one the person chose (POST /desk/folder): a folder named by the environment, a proof's or a
87
+ // scratch run's, is not remembered for them
88
+ return startHost({
89
+ home,
90
+ workspace,
91
+ settings,
92
+ ...(noticeLog() !== undefined ? { notify: noticeLog() } : {}),
93
+ exposeThinking: process.env["KISO_WEB_THINKING"] !== "0",
94
+ requestLog: process.env["KISO_WEB_REQUEST_LOG"] || undefined,
95
+ previewLog: process.env["KISO_WEB_PREVIEW_LOG"] || undefined,
96
+ ...(options.port !== undefined ? { port: options.port } : {}),
97
+ ...(process.env["KISO_WEB_TOKEN"] !== undefined ? { token: process.env["KISO_WEB_TOKEN"] } : {}),
98
+ sanitizer: process.env["KISO_WEB_SANITIZER"] === "default" ? "default" : "web",
99
+ ...(Number.isFinite(keepalive) && keepalive > 0 ? { keepaliveMs: keepalive } : {}),
100
+ ...(process.env["KISO_WEB_MIN_FG_MS"] !== undefined && Number.isFinite(minFg) ? { toolLimits: { minForegroundMs: minFg } } : {}),
101
+ });
102
+ }
@@ -0,0 +1,12 @@
1
+ import { SessionStore } from "@vincemakes/kiso-runtime";
2
+ /** How many faux-script turns a session's log has consumed (the same rule the terminal's fauxSkip states: a turn is consumed by a tool
3
+ * result or an end_turn stop, or by an unfinished call the recovery completes). */
4
+ export function fauxSkip(sessionsDir, sessionId) {
5
+ const store = new SessionStore(sessionsDir);
6
+ const events = store.load(sessionId).map((r) => r.event);
7
+ store.closeAll();
8
+ const results = new Set(events.filter((e) => e.type === "tool_result").map((e) => e.callId));
9
+ return (events.filter((e) => e.type === "tool_result").length +
10
+ events.filter((e) => e.type === "stop" && e.reason === "end_turn").length +
11
+ events.filter((e) => e.type === "tool_call_end" && !results.has(e.callId)).length);
12
+ }
@@ -0,0 +1,156 @@
1
+ import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
2
+ import { extname, isAbsolute, join, resolve, sep } from "node:path";
3
+ import { canonicalPath } from "@vincemakes/kiso-code/config";
4
+ import { kisoHome } from "@vincemakes/kiso-runtime";
5
+ import { protectedFiles } from "./agent.js";
6
+ /**
7
+ * The read-only doors to a Work's folder, for the Files and Changes panels.
8
+ *
9
+ * What may be read is decided here, never by the page: a path is relative to
10
+ * the folder's real path; it is resolved through every symbolic link and must
11
+ * land inside the folder; the kiso home (credentials, sessions) and the
12
+ * files the policy protects are refused even when they sit inside the folder;
13
+ * a file is read up to a size cap and a file with a NUL byte is never read as
14
+ * text. A link that leads outside is listed as such and never followed.
15
+ */
16
+ export const TEXT_CAP = 256 * 1024;
17
+ export const IMAGE_CAP = 2 * 1024 * 1024;
18
+ const LIST_CAP = 500;
19
+ const LOG_CAP = 64 * 1024;
20
+ const IMAGES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml" };
21
+ /** A refusal with the status the host answers. */
22
+ export class Refusal extends Error {
23
+ status;
24
+ constructor(status, message) {
25
+ super(message);
26
+ this.status = status;
27
+ }
28
+ }
29
+ const inside = (root, real) => real === root || real.startsWith(root + sep);
30
+ const folded = (name, dir) => dir && (name === "node_modules" || name.startsWith("."));
31
+ /** Places no door reads: the kiso home and the policy's protected files, by real path. */
32
+ function protectedPaths() {
33
+ return { dirs: [canonicalPath(kisoHome())], files: protectedFiles().map(canonicalPath) };
34
+ }
35
+ function isProtected(real) {
36
+ const p = protectedPaths();
37
+ return p.dirs.some((d) => inside(d, real)) || p.files.includes(real);
38
+ }
39
+ /** The real path of `rel` inside `root` (a real path), or a Refusal. */
40
+ export function resolveInside(root, rel) {
41
+ if (rel.includes("\0") || isAbsolute(rel))
42
+ throw new Refusal(400, "the path must be relative to the folder");
43
+ const target = resolve(root, rel);
44
+ if (!inside(root, target))
45
+ throw new Refusal(403, "outside the folder");
46
+ // a folder the listing does not open is not read through either (a dot folder can hold credentials)
47
+ const parts = rel.split("/").filter((p) => p !== "" && p !== ".");
48
+ if (parts.slice(0, -1).some((p) => folded(p, true)))
49
+ throw new Refusal(403, "not listed");
50
+ let real;
51
+ try {
52
+ real = realpathSync(target);
53
+ }
54
+ catch {
55
+ throw new Refusal(404, "no such file");
56
+ }
57
+ if (!inside(root, real))
58
+ throw new Refusal(403, "outside the folder");
59
+ if (isProtected(real))
60
+ throw new Refusal(403, "protected");
61
+ return real;
62
+ }
63
+ export function listDir(root, rel) {
64
+ const dir = resolveInside(root, rel);
65
+ if (!statSync(dir).isDirectory())
66
+ throw new Refusal(400, "not a folder");
67
+ const last = rel.split("/").filter((p) => p !== "" && p !== ".").at(-1);
68
+ if (last !== undefined && folded(last, true))
69
+ throw new Refusal(403, "not listed");
70
+ const entries = [];
71
+ let more = 0;
72
+ const names = readdirSync(dir, { withFileTypes: true }).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
73
+ for (const ent of names) {
74
+ const full = join(dir, ent.name);
75
+ let real;
76
+ try {
77
+ real = realpathSync(full);
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ if (isProtected(real))
83
+ continue;
84
+ if (entries.length >= LIST_CAP) {
85
+ more += 1;
86
+ continue;
87
+ }
88
+ const link = lstatSync(full).isSymbolicLink();
89
+ if (!inside(root, real)) {
90
+ entries.push({ name: ent.name, kind: "link", size: 0, outside: true });
91
+ continue;
92
+ }
93
+ const stat = statSync(real);
94
+ const isDir = stat.isDirectory();
95
+ entries.push({ name: ent.name, kind: link && !isDir ? "link" : isDir ? "dir" : "file", size: isDir ? 0 : stat.size, ...(folded(ent.name, isDir) ? { folded: true } : {}) });
96
+ }
97
+ return { root, path: rel, entries, more };
98
+ }
99
+ function readHead(path, length) {
100
+ const fd = openSync(path, "r");
101
+ try {
102
+ const buf = Buffer.alloc(Math.min(length, fstatSync(fd).size));
103
+ readSync(fd, buf, 0, buf.length, 0);
104
+ return buf;
105
+ }
106
+ finally {
107
+ closeSync(fd);
108
+ }
109
+ }
110
+ export function readPreview(root, rel) {
111
+ const file = resolveInside(root, rel);
112
+ const stat = statSync(file);
113
+ if (!stat.isFile())
114
+ throw new Refusal(400, "not a file");
115
+ const mime = IMAGES[extname(file).toLowerCase()];
116
+ if (mime !== undefined)
117
+ return stat.size > IMAGE_CAP ? { kind: "toolarge", size: stat.size } : { kind: "image", size: stat.size, dataUrl: `data:${mime};base64,${readHead(file, IMAGE_CAP).toString("base64")}` };
118
+ const head = readHead(file, TEXT_CAP);
119
+ // a NUL byte in the first 8 KB: not text, however it is named
120
+ if (head.subarray(0, 8192).includes(0))
121
+ return { kind: "binary", size: stat.size };
122
+ const markdown = /\.(md|markdown)$/i.test(file);
123
+ return { kind: markdown ? "markdown" : "text", size: stat.size, text: head.toString("utf8"), truncated: stat.size > TEXT_CAP };
124
+ }
125
+ /** Which of the paths name a file inside the folder (the stream's chips): each judged as a read would be; a refusal is "no". */
126
+ export function existing(root, paths) {
127
+ if (!Array.isArray(paths) || paths.length > 64)
128
+ throw new Refusal(400, "at most 64 paths");
129
+ return paths.filter((p) => typeof p === "string" && p.length <= 512).filter((rel) => {
130
+ try {
131
+ return statSync(resolveInside(root, rel)).isFile();
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ });
137
+ }
138
+ /** The last part of a task's output file, as lines. */
139
+ export function readTaskLog(outputPath) {
140
+ try {
141
+ const size = statSync(outputPath).size;
142
+ const fd = openSync(outputPath, "r");
143
+ try {
144
+ const length = Math.min(size, LOG_CAP);
145
+ const buf = Buffer.alloc(length);
146
+ readSync(fd, buf, 0, length, size - length);
147
+ return { lines: buf.toString("utf8").split("\n").filter((l, i, all) => l !== "" || i < all.length - 1), truncated: size > LOG_CAP };
148
+ }
149
+ finally {
150
+ closeSync(fd);
151
+ }
152
+ }
153
+ catch {
154
+ return { lines: [], truncated: false };
155
+ }
156
+ }
@@ -0,0 +1,36 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ /** The git directory of a folder (it or the nearest parent holding `.git`, a directory or a worktree's pointer file). */
5
+ function gitDir(from) {
6
+ for (let dir = from;; dir = dirname(dir)) {
7
+ const dot = join(dir, ".git");
8
+ if (existsSync(dot)) {
9
+ if (statSync(dot).isDirectory())
10
+ return dot;
11
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, "utf8"));
12
+ return pointer === null ? null : join(dir, pointer[1].trim());
13
+ }
14
+ if (dirname(dir) === dir)
15
+ return null;
16
+ }
17
+ }
18
+ /** The branch a folder is on (a short commit when detached); null outside a git folder. */
19
+ function branchOf(workspace) {
20
+ try {
21
+ const git = gitDir(workspace);
22
+ if (git === null)
23
+ return null;
24
+ const head = readFileSync(join(git, "HEAD"), "utf8").trim();
25
+ const ref = /^ref: refs\/heads\/(.+)$/.exec(head);
26
+ return ref !== null ? ref[1] : head.slice(0, 7);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** What the rail's footer says about a Work's folder: its name, its path (the home shortened), its branch. */
33
+ export function folderInfo(workspace, mode) {
34
+ const home = homedir();
35
+ return { folder: basename(workspace), path: workspace.startsWith(`${home}/`) ? `~${workspace.slice(home.length)}` : workspace, root: workspace, branch: branchOf(workspace), mode };
36
+ }
@@ -0,0 +1,102 @@
1
+ import { accessSync, constants, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, parse, relative, sep } from "node:path";
4
+ import { canonicalPath } from "@vincemakes/kiso-code/config";
5
+ import { kisoHome } from "@vincemakes/kiso-runtime";
6
+ import { Refusal } from "./files.js";
7
+ /** A folder the person picked, judged here and never trusted from the page: absolute, there, a folder, and not the kiso home or inside it. Returns its real path. */
8
+ export function validateFolder(raw) {
9
+ if (typeof raw !== "string" || raw === "" || raw.includes("\0") || !isAbsolute(raw))
10
+ throw new Refusal(400, "pick a folder");
11
+ let real;
12
+ try {
13
+ real = canonicalPath(raw);
14
+ if (!statSync(real).isDirectory())
15
+ throw new Error("not a folder");
16
+ }
17
+ catch {
18
+ throw new Refusal(400, "that is not a folder that exists");
19
+ }
20
+ if (real === parse(real).root)
21
+ throw new Refusal(400, "the whole disk is not a project folder");
22
+ const home = canonicalPath(kisoHome());
23
+ if (real === home || real.startsWith(home + sep))
24
+ throw new Refusal(400, "the kiso home holds credentials and sessions: pick a project folder");
25
+ return real;
26
+ }
27
+ const DIRS_CAP = 500;
28
+ const inside = (root, path) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
29
+ /** A root of the folder browser: the home folder, or a typed absolute path that exists, is a readable folder, is not the whole disk and not the kiso home. */
30
+ function browseRoot(raw) {
31
+ if (raw === null || raw === "")
32
+ return realpathSync(homedir());
33
+ if (raw.includes("\0") || !isAbsolute(raw))
34
+ throw new Refusal(400, "type a full path, starting with /");
35
+ let real;
36
+ try {
37
+ real = realpathSync(raw);
38
+ if (!statSync(real).isDirectory())
39
+ throw new Error("not a folder");
40
+ accessSync(real, constants.R_OK | constants.X_OK);
41
+ }
42
+ catch {
43
+ throw new Refusal(400, "that is not a folder that exists and can be read");
44
+ }
45
+ if (real === parse(real).root)
46
+ throw new Refusal(400, "the whole disk is not a place to browse: type a folder in it");
47
+ if (inside(realpathSync(kisoHome()), real))
48
+ throw new Refusal(400, "the kiso home holds credentials and sessions");
49
+ return real;
50
+ }
51
+ /**
52
+ * One folder of the in-page folder browser: the folders in it, by name, nothing else (no files, no contents). The path is relative to
53
+ * the root and resolved through every link; it must stay inside the root, so a link never leads the browsing out of it (such a link is
54
+ * listed as `outside`). Dot folders and the kiso home are not listed.
55
+ */
56
+ export function listDirs(rootRaw, rel) {
57
+ const root = browseRoot(rootRaw);
58
+ if (rel.includes("\0") || isAbsolute(rel) || rel.split(/[\\/]/).includes(".."))
59
+ throw new Refusal(400, "a path inside the root is expected");
60
+ let full;
61
+ try {
62
+ full = realpathSync(join(root, rel));
63
+ if (!statSync(full).isDirectory())
64
+ throw new Error("not a folder");
65
+ }
66
+ catch {
67
+ throw new Refusal(404, "no such folder");
68
+ }
69
+ const home = realpathSync(kisoHome());
70
+ if (!inside(root, full) || inside(home, full))
71
+ throw new Refusal(403, "outside the folder you are browsing");
72
+ const dirs = [];
73
+ let entries;
74
+ try {
75
+ entries = readdirSync(full, { withFileTypes: true });
76
+ }
77
+ catch {
78
+ throw new Refusal(403, "that folder cannot be read");
79
+ }
80
+ for (const entry of entries) {
81
+ if (entry.name.startsWith("."))
82
+ continue;
83
+ const path = join(full, entry.name);
84
+ if (entry.isDirectory()) {
85
+ if (!inside(home, path))
86
+ dirs.push({ name: entry.name });
87
+ }
88
+ else if (entry.isSymbolicLink()) {
89
+ try {
90
+ const target = realpathSync(path);
91
+ if (!statSync(target).isDirectory() || inside(home, target))
92
+ continue;
93
+ dirs.push(inside(root, target) ? { name: entry.name } : { name: entry.name, outside: true });
94
+ }
95
+ catch {
96
+ // a broken link is not a folder
97
+ }
98
+ }
99
+ }
100
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
101
+ return { root, path: relative(root, full), full, dirs: dirs.slice(0, DIRS_CAP), more: Math.max(0, dirs.length - DIRS_CAP) };
102
+ }
@@ -0,0 +1,37 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { listDir, readPreview, readTaskLog, Refusal } from "./files.js";
3
+ /**
4
+ * The read doors of the page: a Work's folder (a tree, a file) and a
5
+ * background task's log. GET only, behind the host's guard; every path is
6
+ * judged by files.ts, a task id only by the task manager of a session that
7
+ * exists in the Work's folder.
8
+ */
9
+ const SAFE_ID = /^[A-Za-z0-9._-]+$/;
10
+ export function deskRead(url, space) {
11
+ try {
12
+ const rel = url.searchParams.get("path") ?? "";
13
+ if (url.pathname === "/desk/task-log") {
14
+ const session = url.searchParams.get("session") ?? "";
15
+ const task = url.searchParams.get("task") ?? "";
16
+ if (!SAFE_ID.test(session) || !SAFE_ID.test(task) || !space.store.ids().includes(session))
17
+ throw new Refusal(404, "no such task");
18
+ const info = space.tasks.managerFor(session).list().find((t) => t.id === task);
19
+ if (info === undefined)
20
+ throw new Refusal(404, "no such task");
21
+ return { status: 200, body: readTaskLog(info.outputPath) };
22
+ }
23
+ let root;
24
+ try {
25
+ root = realpathSync(space.workspace);
26
+ }
27
+ catch {
28
+ throw new Refusal(404, "the folder is gone");
29
+ }
30
+ return { status: 200, body: url.pathname === "/desk/fs/tree" ? listDir(root, rel) : readPreview(root, rel) };
31
+ }
32
+ catch (error) {
33
+ if (error instanceof Refusal)
34
+ return { status: error.status, body: { code: "refused", message: error.message } };
35
+ return { status: 500, body: { code: "internal", message: "unreadable" } };
36
+ }
37
+ }
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { kisoHome } from "@vincemakes/kiso-runtime";
6
+ import { revokeSessions } from "./auth.js";
7
+ import { startFromEnv } from "./env.js";
8
+ /**
9
+ * `kiso-web [--port 4317] [--no-open] [--revoke-sessions]`: the host in this process, the page at http://127.0.0.1:<port>, and the
10
+ * default browser opened once on `#c=<one-time connect code>`. The port is fixed (a page added to the Dock keeps its address): if
11
+ * another kiso-web host holds it, the browser is opened on that host; anything else holding it is an error that names the process.
12
+ * The server runs while the command runs. Configuration beyond the flags: env.ts. It prints `READY {json}` once it listens (the proofs
13
+ * read it); with `--no-open` it also prints the address to open, whose code is good once, for two minutes.
14
+ */
15
+ const DEFAULT_PORT = 4317;
16
+ const USAGE = "usage: kiso-web [--port <n>] [--no-open] [--revoke-sessions]";
17
+ const say = (line) => void process.stdout.write(`${line}\n`);
18
+ const fail = (line) => {
19
+ process.stderr.write(`kiso-web: ${line}\n`);
20
+ process.exit(1);
21
+ };
22
+ function parseArgs(argv) {
23
+ let port = Number(process.env["KISO_WEB_PORT"] || DEFAULT_PORT);
24
+ let open = true;
25
+ let revoke = false;
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const arg = argv[i];
28
+ if (arg === "--no-open")
29
+ open = false;
30
+ else if (arg === "--revoke-sessions")
31
+ revoke = true;
32
+ else if (arg === "--port" || arg.startsWith("--port="))
33
+ port = Number(arg.includes("=") ? arg.slice(7) : argv[++i]);
34
+ else if (arg === "--help" || arg === "-h")
35
+ (say(USAGE), process.exit(0));
36
+ else
37
+ fail(`unknown argument ${arg}\n${USAGE}`);
38
+ }
39
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
40
+ fail(`--port must be a port number\n${USAGE}`);
41
+ return { port, open, revoke };
42
+ }
43
+ /** Where a running host leaves its scripts' token for another `kiso-web` on the same port (mode 0600, removed on exit). */
44
+ const hostFile = (home, port) => join(home, `web-host-${port}.json`);
45
+ /** Who answers on the port: a kiso-web host, something else, or nobody. */
46
+ async function probe(port) {
47
+ try {
48
+ const r = await fetch(`http://127.0.0.1:${port}/auth/status`, { signal: AbortSignal.timeout(1_500) });
49
+ return (await r.json().catch(() => ({}))).app === "kiso-web" ? "kiso-web" : "other";
50
+ }
51
+ catch (error) {
52
+ const code = error.cause?.code;
53
+ return code === "ECONNREFUSED" ? "free" : "other";
54
+ }
55
+ }
56
+ /** The process listening on the port, in words, for the error. */
57
+ function holder(port) {
58
+ try {
59
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
60
+ const pid = /^p(\d+)$/m.exec(out)?.[1];
61
+ const command = /^c(.+)$/m.exec(out)?.[1];
62
+ if (pid !== undefined)
63
+ return `${command ?? "a process"} (pid ${pid})`;
64
+ }
65
+ catch {
66
+ // no lsof, or nothing found
67
+ }
68
+ return "another program";
69
+ }
70
+ function openBrowser(url) {
71
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
72
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
73
+ child.on("error", () => say(`Open ${url} in your browser.`));
74
+ child.unref();
75
+ }
76
+ /** A connect address from the host already on the port, by the token it left; without one, the bare address (a browser signed in before still is). */
77
+ async function addressFrom(home, port) {
78
+ const origin = `http://127.0.0.1:${port}`;
79
+ try {
80
+ const { token } = JSON.parse(readFileSync(hostFile(home, port), "utf8"));
81
+ const r = await fetch(`${origin}/auth/code`, { method: "POST", headers: { authorization: `Bearer ${token}` } });
82
+ if (r.ok)
83
+ return `${origin}/#c=${(await r.json()).code}`;
84
+ }
85
+ catch {
86
+ // fall through
87
+ }
88
+ return `${origin}/`;
89
+ }
90
+ async function main() {
91
+ const { port, open, revoke } = parseArgs(process.argv.slice(2));
92
+ const home = process.env["KISO_WEB_DATA"] ?? kisoHome();
93
+ if (revoke)
94
+ return say(`kiso-web: ${revokeSessions(home)} session(s) revoked; each browser signs in again from kiso web.`);
95
+ if (port !== 0) {
96
+ const found = await probe(port);
97
+ if (found === "kiso-web") {
98
+ const url = await addressFrom(home, port);
99
+ if (open)
100
+ openBrowser(url);
101
+ return say(`kiso web is already running at http://127.0.0.1:${port}${open ? "; opened it in your browser" : `; open ${url}`}.`);
102
+ }
103
+ if (found === "other")
104
+ fail(`port ${port} is held by ${holder(port)}. Stop it, or start kiso web on another port with --port.`);
105
+ }
106
+ const host = await startFromEnv({ port }).catch((error) => error.code === "EADDRINUSE" ? fail(`port ${port} is held by ${holder(port)}. Stop it, or start kiso web on another port with --port.`) : Promise.reject(error));
107
+ const record = hostFile(home, host.port);
108
+ writeFileSync(record, JSON.stringify({ pid: process.pid, token: host.token }), { mode: 0o600 });
109
+ chmodSync(record, 0o600);
110
+ const url = `${host.origin}/#c=${host.connectCode()}`;
111
+ process.stdout.write(`READY ${JSON.stringify({ origin: host.origin, port: host.port, pid: process.pid, openRuns: host.service.openRuns(), ...(open ? {} : { connect: url }) })}\n`);
112
+ say(`kiso web is running at ${host.origin} (Ctrl-C stops it).`);
113
+ if (open)
114
+ openBrowser(url);
115
+ else
116
+ say(`Open ${url} (the code in it works once, for two minutes).`);
117
+ let stopping = false;
118
+ const stop = () => {
119
+ if (stopping)
120
+ return;
121
+ stopping = true;
122
+ rmSync(record, { force: true });
123
+ void host.close(5_000).then(() => process.exit(0));
124
+ };
125
+ process.on("SIGTERM", stop);
126
+ process.on("SIGINT", stop);
127
+ }
128
+ main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
@@ -0,0 +1,18 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ import { SessionStore } from "@vincemakes/kiso-runtime";
3
+ /**
4
+ * Lists one session folder off the host's thread: `SessionStore.list()` reads
5
+ * every log whole to title it, and one folder of long sessions takes seconds.
6
+ * Message in: `{ dir }`. Message out: `{ dir, metas }` or `{ dir, error }`.
7
+ */
8
+ parentPort.on("message", (message) => {
9
+ try {
10
+ const store = new SessionStore(message.dir);
11
+ const metas = store.list();
12
+ store.closeAll();
13
+ parentPort.postMessage({ dir: message.dir, metas });
14
+ }
15
+ catch (error) {
16
+ parentPort.postMessage({ dir: message.dir, error: error instanceof Error ? error.message : String(error) });
17
+ }
18
+ });