@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +928 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +843 -0
- package/server/config.ts +362 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +966 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +654 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
package/server/mcp.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Which MCP servers this box has, and how to hand one of them to a profile.
|
|
2
|
+
//
|
|
3
|
+
// Claude has no "use only these servers" flag that takes names. What it has is
|
|
4
|
+
// `--mcp-config`, which takes definitions, and `--strict-mcp-config`, which
|
|
5
|
+
// says to ignore everything else. So picking servers per profile means writing
|
|
6
|
+
// definitions somewhere and pointing at them.
|
|
7
|
+
//
|
|
8
|
+
// Copying a definition into the profile's arguments would work and is wrong for
|
|
9
|
+
// two reasons: it puts whatever the server needs to authenticate — an API key
|
|
10
|
+
// in `env`, a bearer token in `headers` — into a command line that shows up in
|
|
11
|
+
// `ps`, and it freezes a copy that stops matching the box the moment the URL
|
|
12
|
+
// changes. So the profile stores a *path* and the file behind it is regenerated
|
|
13
|
+
// from the box's own config, the same way the model list is read off the
|
|
14
|
+
// installed binary rather than hardcoded. Change a server with `claude mcp add`
|
|
15
|
+
// and every profile using it follows.
|
|
16
|
+
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import {
|
|
19
|
+
mkdir,
|
|
20
|
+
readdir,
|
|
21
|
+
readFile,
|
|
22
|
+
stat,
|
|
23
|
+
unlink,
|
|
24
|
+
writeFile,
|
|
25
|
+
} from "node:fs/promises";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
/**
|
|
29
|
+
* Where Claude keeps a server definition, which decides how long it lives.
|
|
30
|
+
*
|
|
31
|
+
* - `user` is `mcpServers` in `~/.claude.json` — the whole machine, permanent.
|
|
32
|
+
* - `project` is a `.mcp.json` checked into the repo — shared with whoever
|
|
33
|
+
* clones it.
|
|
34
|
+
* - `local` is `projects."<dir>".mcpServers` in `~/.claude.json`, which is what
|
|
35
|
+
* a bare `claude mcp add` writes. It is keyed by the directory it was run in,
|
|
36
|
+
* so one added inside a workspace is gone with the worktree. Surfaced anyway,
|
|
37
|
+
* labelled with that directory, because otherwise the answer to "I added it,
|
|
38
|
+
* where is it?" is nowhere.
|
|
39
|
+
*/
|
|
40
|
+
import type { McpScope, McpServer } from "../shared/wire.ts";
|
|
41
|
+
import { log } from "./log.ts";
|
|
42
|
+
|
|
43
|
+
export type { McpScope, McpServer };
|
|
44
|
+
|
|
45
|
+
/** Directories to look in. Kept separate because they mean different things. */
|
|
46
|
+
export interface McpScan {
|
|
47
|
+
/** Project roots, whose `.mcp.json` is the project scope. */
|
|
48
|
+
roots: string[];
|
|
49
|
+
/** Directories that may hold local-scope servers: roots and their worktrees. */
|
|
50
|
+
dirs: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Definition {
|
|
54
|
+
type?: string;
|
|
55
|
+
transport?: string;
|
|
56
|
+
url?: string;
|
|
57
|
+
command?: string;
|
|
58
|
+
args?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `~/.claude.json` for the account the sessions run as.
|
|
63
|
+
*
|
|
64
|
+
* `$HOME` first, `os.homedir()` after: the environment is what a service unit
|
|
65
|
+
* sets and what an operator can override, while the passwd entry is the right
|
|
66
|
+
* answer when nothing set it at all. This assumes the server and the SSH
|
|
67
|
+
* sessions are the same user, which is what the deployment instructions set up
|
|
68
|
+
* and what `harness.ts` already assumes when it looks for the binary.
|
|
69
|
+
*/
|
|
70
|
+
const claudeConfigPath = (): string =>
|
|
71
|
+
path.join(process.env.HOME || os.homedir(), ".claude.json");
|
|
72
|
+
|
|
73
|
+
/** Where the generated single-server files live. Under the state dir, 0700. */
|
|
74
|
+
export const mirrorDir = (stateDir: string): string =>
|
|
75
|
+
path.join(stateDir, "mcp");
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A stable filename for one server.
|
|
79
|
+
*
|
|
80
|
+
* Stable is the whole requirement: a profile stores this path, so it cannot
|
|
81
|
+
* change because some unrelated server appeared or went away. A plain
|
|
82
|
+
* `linear.json` is worth having for the common case — it is what the Advanced
|
|
83
|
+
* preview shows — so a user-scope server with a filename-safe name gets it, and
|
|
84
|
+
* everything else is disambiguated by a hash of what identifies it.
|
|
85
|
+
*/
|
|
86
|
+
function mirrorName(scope: McpScope, source: string, name: string): string {
|
|
87
|
+
if (scope === "user" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/.test(name))
|
|
88
|
+
return `${name}.json`;
|
|
89
|
+
const slug =
|
|
90
|
+
name
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
93
|
+
.replace(/^-|-$/g, "")
|
|
94
|
+
.slice(0, 32) || "server";
|
|
95
|
+
const hash = createHash("sha256")
|
|
96
|
+
.update(`${scope}\0${source}\0${name}`)
|
|
97
|
+
.digest("hex")
|
|
98
|
+
.slice(0, 8);
|
|
99
|
+
return `${slug}-${hash}.json`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `http`/`sse` when there is a URL, `stdio` otherwise. Claude's own default. */
|
|
103
|
+
function transportOf(def: Definition): string {
|
|
104
|
+
return def.type ?? def.transport ?? (def.url ? "http" : "stdio");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function detailOf(def: Definition): string {
|
|
108
|
+
if (def.url) return def.url;
|
|
109
|
+
return [def.command ?? "", ...(def.args ?? [])].join(" ").trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readJson(file: string): Promise<Record<string, unknown> | null> {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
|
115
|
+
} catch {
|
|
116
|
+
// Absent is the common case and not worth a line; malformed is the box's
|
|
117
|
+
// problem to fix and would be reported by Claude itself.
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const asServers = (value: unknown): Record<string, Definition> =>
|
|
123
|
+
value && typeof value === "object"
|
|
124
|
+
? (value as Record<string, Definition>)
|
|
125
|
+
: {};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `~/.claude.json`, parsed at most once per change.
|
|
129
|
+
*
|
|
130
|
+
* It is a hundred-odd kilobytes on a machine that has been used, and this is
|
|
131
|
+
* read on every window attach as well as by the editor, so it is cached against
|
|
132
|
+
* the file's size and mtime rather than re-parsed each time.
|
|
133
|
+
*/
|
|
134
|
+
let cache: { key: string; data: Record<string, unknown> | null } | null = null;
|
|
135
|
+
|
|
136
|
+
async function claudeConfig(): Promise<Record<string, unknown> | null> {
|
|
137
|
+
const file = claudeConfigPath();
|
|
138
|
+
let key = "missing";
|
|
139
|
+
try {
|
|
140
|
+
const info = await stat(file);
|
|
141
|
+
key = `${info.size}:${info.mtimeMs}`;
|
|
142
|
+
} catch {
|
|
143
|
+
// fall through with the sentinel; a missing file still caches as "nothing"
|
|
144
|
+
}
|
|
145
|
+
if (cache?.key === key) return cache.data;
|
|
146
|
+
const data = key === "missing" ? null : await readJson(file);
|
|
147
|
+
cache = { key, data };
|
|
148
|
+
return data;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every MCP server visible to the given directories.
|
|
153
|
+
*
|
|
154
|
+
* Project scope reads only the roots: a worktree is a copy of the repo, so
|
|
155
|
+
* scanning worktrees too would list the same `.mcp.json` once per workspace.
|
|
156
|
+
*/
|
|
157
|
+
/**
|
|
158
|
+
* A discovered server together with the definition it was parsed from.
|
|
159
|
+
*
|
|
160
|
+
* The definition carries API keys and bearer tokens, so it deliberately does
|
|
161
|
+
* not travel on `McpServer`, which is a wire type the browser receives.
|
|
162
|
+
*/
|
|
163
|
+
export interface DiscoveredMcp {
|
|
164
|
+
server: McpServer;
|
|
165
|
+
def: Definition;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Discovery that keeps the parsed definitions, for the mirror writer. */
|
|
169
|
+
export async function discoverMcpWithDefs(
|
|
170
|
+
stateDir: string,
|
|
171
|
+
scan: McpScan,
|
|
172
|
+
): Promise<DiscoveredMcp[]> {
|
|
173
|
+
const found: DiscoveredMcp[] = [];
|
|
174
|
+
const add = (
|
|
175
|
+
scope: McpScope,
|
|
176
|
+
source: string,
|
|
177
|
+
name: string,
|
|
178
|
+
def: Definition,
|
|
179
|
+
) => {
|
|
180
|
+
found.push({
|
|
181
|
+
def,
|
|
182
|
+
server: {
|
|
183
|
+
name,
|
|
184
|
+
scope,
|
|
185
|
+
source,
|
|
186
|
+
transport: transportOf(def),
|
|
187
|
+
detail: detailOf(def),
|
|
188
|
+
configPath: path.join(
|
|
189
|
+
mirrorDir(stateDir),
|
|
190
|
+
mirrorName(scope, source, name),
|
|
191
|
+
),
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const config = await claudeConfig();
|
|
197
|
+
if (config) {
|
|
198
|
+
for (const [name, def] of Object.entries(asServers(config.mcpServers))) {
|
|
199
|
+
add("user", claudeConfigPath(), name, def);
|
|
200
|
+
}
|
|
201
|
+
const projects = asServers(config.projects) as unknown as Record<
|
|
202
|
+
string,
|
|
203
|
+
{ mcpServers?: unknown }
|
|
204
|
+
>;
|
|
205
|
+
for (const dir of new Set(scan.dirs)) {
|
|
206
|
+
for (const [name, def] of Object.entries(
|
|
207
|
+
asServers(projects[dir]?.mcpServers),
|
|
208
|
+
)) {
|
|
209
|
+
add("local", dir, name, def);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const root of new Set(scan.roots)) {
|
|
215
|
+
const file = path.join(root, ".mcp.json");
|
|
216
|
+
const data = await readJson(file);
|
|
217
|
+
if (!data) continue;
|
|
218
|
+
for (const [name, def] of Object.entries(asServers(data.mcpServers)))
|
|
219
|
+
add("project", file, name, def);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Just the wire shape, for the API and the profile editor. */
|
|
226
|
+
export async function discoverMcp(
|
|
227
|
+
stateDir: string,
|
|
228
|
+
scan: McpScan,
|
|
229
|
+
): Promise<McpServer[]> {
|
|
230
|
+
return (await discoverMcpWithDefs(stateDir, scan)).map((d) => d.server);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const NOTE = `Generated by vibe-os. One file per MCP server on this box, each holding just
|
|
234
|
+
that server, so a profile can ask for exactly the ones it wants with
|
|
235
|
+
--mcp-config. Rewritten from ~/.claude.json and each project's .mcp.json
|
|
236
|
+
whenever the list is read or a window starts, so edit those, not these.
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Writes one file per server and removes the ones that no longer have a server.
|
|
241
|
+
*
|
|
242
|
+
* Pruning is deliberate: a profile pointing at a server that has been deleted
|
|
243
|
+
* should fail visibly at launch, not keep working from a copy nobody can see.
|
|
244
|
+
* Files are only rewritten when the contents actually differ, so this stays
|
|
245
|
+
* cheap enough to run on every window attach.
|
|
246
|
+
*/
|
|
247
|
+
/**
|
|
248
|
+
* Writes one file per MCP server, so a profile can point `--mcp-config` at
|
|
249
|
+
* exactly the ones it wants.
|
|
250
|
+
*
|
|
251
|
+
* Takes the definitions discovery already parsed rather than looking each one
|
|
252
|
+
* up again: the previous version re-read and re-parsed the source file once per
|
|
253
|
+
* server, so a project with five servers in one `.mcp.json` read that file five
|
|
254
|
+
* times — on every certificate request.
|
|
255
|
+
*/
|
|
256
|
+
export async function syncMcpMirrors(
|
|
257
|
+
stateDir: string,
|
|
258
|
+
discovered: DiscoveredMcp[],
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
const dir = mirrorDir(stateDir);
|
|
261
|
+
|
|
262
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
263
|
+
|
|
264
|
+
const wanted = new Set<string>();
|
|
265
|
+
for (const { server, def } of discovered) {
|
|
266
|
+
if (!def) continue;
|
|
267
|
+
wanted.add(path.basename(server.configPath));
|
|
268
|
+
const body = `${JSON.stringify({ mcpServers: { [server.name]: def } }, null, 2)}\n`;
|
|
269
|
+
// Definitions carry API keys and bearer tokens, so 0600 and never anywhere
|
|
270
|
+
// the web root can reach.
|
|
271
|
+
if (
|
|
272
|
+
(await readFile(server.configPath, "utf8").catch(() => null)) !== body
|
|
273
|
+
) {
|
|
274
|
+
await writeFile(server.configPath, body, { mode: 0o600 });
|
|
275
|
+
log.debug(`wrote mcp config for ${server.name} (${server.scope})`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Compared first, like the json files beside it: this runs on every
|
|
280
|
+
// certificate request, and rewriting an unchanged file each time is a write
|
|
281
|
+
// for nothing.
|
|
282
|
+
const readmePath = path.join(dir, "README");
|
|
283
|
+
if ((await readFile(readmePath, "utf8").catch(() => null)) !== NOTE)
|
|
284
|
+
await writeFile(readmePath, NOTE, { mode: 0o600 }).catch(() => {});
|
|
285
|
+
|
|
286
|
+
for (const entry of await readdir(dir).catch(() => [] as string[])) {
|
|
287
|
+
if (!entry.endsWith(".json") || wanted.has(entry)) continue;
|
|
288
|
+
await unlink(path.join(dir, entry)).catch(() => {});
|
|
289
|
+
log.debug(`removed stale mcp config ${entry}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, copyFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { isFile } from "./fsx.ts";
|
|
6
|
+
import { log } from "./log.ts";
|
|
7
|
+
import { FETCH_WASM, IS_COMPILED, PKG_ROOT } from "./runtime.ts";
|
|
8
|
+
|
|
9
|
+
const WASM_FILES = ["ssh.wasm", "wasm_exec.js"];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Downloads the pair unconditionally.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately without `--if-missing`: that flag answers "is the pair in
|
|
15
|
+
* public/ *or* in the package's own dist/web", and the only caller has already
|
|
16
|
+
* established that public/ does not have them. With a custom --web-root and a
|
|
17
|
+
* populated dist/web, the flag made the script exit successfully having copied
|
|
18
|
+
* nothing, and ensureWasm then reported the files as unfetchable on a machine
|
|
19
|
+
* that had them.
|
|
20
|
+
*/
|
|
21
|
+
function runFetchScript(): Promise<void> {
|
|
22
|
+
const script = FETCH_WASM;
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(process.execPath, [script], {
|
|
25
|
+
stdio: "inherit",
|
|
26
|
+
});
|
|
27
|
+
child.on("error", reject);
|
|
28
|
+
child.on("close", (code) =>
|
|
29
|
+
code === 0 ? resolve() : reject(new Error(`fetch-wasm exited ${code}`)),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Makes sure the WASM pair is present in the served directory.
|
|
36
|
+
*
|
|
37
|
+
* These are the only two files that are not built from this repo, so they can
|
|
38
|
+
* legitimately be missing on a fresh checkout or after a failed (soft-failing)
|
|
39
|
+
* postinstall. Fetching here rather than erroring means the first run of
|
|
40
|
+
* `vibe-os start` on a blank VPS still works.
|
|
41
|
+
*/
|
|
42
|
+
export async function ensureWasm(webRoot: string): Promise<void> {
|
|
43
|
+
// A compiled binary carries every asset inside it; there is no directory to
|
|
44
|
+
// check and nothing to fetch.
|
|
45
|
+
if (IS_COMPILED) return;
|
|
46
|
+
|
|
47
|
+
const missing = [];
|
|
48
|
+
for (const name of WASM_FILES) {
|
|
49
|
+
if (!(await isFile(path.join(webRoot, name)))) missing.push(name);
|
|
50
|
+
}
|
|
51
|
+
if (missing.length === 0) return;
|
|
52
|
+
|
|
53
|
+
const publicDir = path.join(PKG_ROOT, "public");
|
|
54
|
+
const stillMissing = [];
|
|
55
|
+
for (const name of missing) {
|
|
56
|
+
if (!(await isFile(path.join(publicDir, name)))) stillMissing.push(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (stillMissing.length > 0) {
|
|
60
|
+
log.info(`fetching ${stillMissing.join(" and ")}…`);
|
|
61
|
+
await runFetchScript();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const name of missing) {
|
|
65
|
+
const from = path.join(publicDir, name);
|
|
66
|
+
if (await isFile(from)) {
|
|
67
|
+
await copyFile(from, path.join(webRoot, name));
|
|
68
|
+
} else {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`${name} is missing from ${webRoot} and could not be fetched`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function ensureWebRoot(webRoot: string): Promise<void> {
|
|
77
|
+
if (IS_COMPILED) return;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await access(path.join(webRoot, "index.html"), constants.R_OK);
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`no built web app at ${webRoot}\n` +
|
|
84
|
+
" If you are running from a git checkout, build it first: bun run build",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Profiles: the roles you can open a terminal as.
|
|
2
|
+
//
|
|
3
|
+
// A profile is a name, a colour, a harness with its flags, and a standing
|
|
4
|
+
// prompt. It belongs to a project, so it is available in every workspace of
|
|
5
|
+
// that project — the prompts and flags describe the codebase, and workspaces
|
|
6
|
+
// are throwaway worktrees of it.
|
|
7
|
+
|
|
8
|
+
import { asc, eq } from "drizzle-orm";
|
|
9
|
+
import { detokenize, tokenize } from "../shared/args.ts";
|
|
10
|
+
import type { Harness, Profile, ProfileInput } from "../shared/wire.ts";
|
|
11
|
+
import { type Db, newId, profiles, windows } from "./db.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The ten colours a profile can be.
|
|
15
|
+
*
|
|
16
|
+
* Stored as tokens rather than hex so the stylesheet stays the single place
|
|
17
|
+
* that decides what "coral" looks like, and so a stored value can be validated
|
|
18
|
+
* instead of trusted.
|
|
19
|
+
*/
|
|
20
|
+
export const PALETTE = [
|
|
21
|
+
"cyan",
|
|
22
|
+
"teal",
|
|
23
|
+
"green",
|
|
24
|
+
"lime",
|
|
25
|
+
"amber",
|
|
26
|
+
"orange",
|
|
27
|
+
"coral",
|
|
28
|
+
"rose",
|
|
29
|
+
"violet",
|
|
30
|
+
"blue",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `satisfies` rather than a plain literal: it is what makes the runtime list and
|
|
35
|
+
* the wire type fail to compile if they ever stop agreeing.
|
|
36
|
+
*/
|
|
37
|
+
export const HARNESSES = [
|
|
38
|
+
"claude",
|
|
39
|
+
"hermes",
|
|
40
|
+
"cursor",
|
|
41
|
+
"shell",
|
|
42
|
+
"custom",
|
|
43
|
+
] as const satisfies readonly Harness[];
|
|
44
|
+
|
|
45
|
+
const MAX_NAME = 40;
|
|
46
|
+
const MAX_PROMPT = 16_000;
|
|
47
|
+
const MAX_ARGS = 64;
|
|
48
|
+
const MAX_TOKEN = 512;
|
|
49
|
+
|
|
50
|
+
export type { Profile, ProfileInput };
|
|
51
|
+
export { detokenize, tokenize };
|
|
52
|
+
|
|
53
|
+
function parseStoredArgs(raw: string): string[] {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
56
|
+
return Array.isArray(parsed)
|
|
57
|
+
? parsed.filter((t): t is string => typeof t === "string")
|
|
58
|
+
: [];
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const hydrate = (row: typeof profiles.$inferSelect): Profile => ({
|
|
65
|
+
id: row.id,
|
|
66
|
+
projectId: row.projectId,
|
|
67
|
+
name: row.name,
|
|
68
|
+
color: row.color,
|
|
69
|
+
harness: row.harness as Harness,
|
|
70
|
+
command: row.command,
|
|
71
|
+
args: parseStoredArgs(row.args),
|
|
72
|
+
prompt: row.prompt,
|
|
73
|
+
position: row.position,
|
|
74
|
+
createdAt: row.createdAt,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Checks and normalises what the editor sent.
|
|
79
|
+
*
|
|
80
|
+
* `partial` is the update case, where an absent field means "leave it alone"
|
|
81
|
+
* rather than "clear it".
|
|
82
|
+
*/
|
|
83
|
+
type Fields = Partial<typeof profiles.$inferInsert>;
|
|
84
|
+
/** What a non-partial validate guarantees: the three columns with no default. */
|
|
85
|
+
type CompleteFields = Fields &
|
|
86
|
+
Required<Pick<typeof profiles.$inferInsert, "name" | "color" | "harness">>;
|
|
87
|
+
|
|
88
|
+
function validate(input: ProfileInput, partial: true): Fields;
|
|
89
|
+
function validate(input: ProfileInput, partial: false): CompleteFields;
|
|
90
|
+
function validate(input: ProfileInput, partial: boolean): Fields {
|
|
91
|
+
const out: Fields = {};
|
|
92
|
+
|
|
93
|
+
if (input.name !== undefined || !partial) {
|
|
94
|
+
const name = (input.name ?? "").trim();
|
|
95
|
+
if (name.length === 0) throw new Error("a profile needs a name");
|
|
96
|
+
if (name.length > MAX_NAME)
|
|
97
|
+
throw new Error(`name must be ${MAX_NAME} characters or fewer`);
|
|
98
|
+
out.name = name;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (input.color !== undefined || !partial) {
|
|
102
|
+
const color = input.color ?? PALETTE[0];
|
|
103
|
+
if (!(PALETTE as readonly string[]).includes(color))
|
|
104
|
+
throw new Error(`unknown colour ${color}`);
|
|
105
|
+
out.color = color;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (input.harness !== undefined || !partial) {
|
|
109
|
+
const harness = input.harness ?? "claude";
|
|
110
|
+
if (!(HARNESSES as readonly string[]).includes(harness))
|
|
111
|
+
throw new Error(`unknown harness ${harness}`);
|
|
112
|
+
out.harness = harness;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (input.command !== undefined || !partial) {
|
|
116
|
+
const command = (input.command ?? "").trim();
|
|
117
|
+
out.command = command === "" ? null : command;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (input.args !== undefined || !partial) {
|
|
121
|
+
const tokens = tokenize(input.args ?? "");
|
|
122
|
+
if (tokens.length > MAX_ARGS)
|
|
123
|
+
throw new Error(`too many arguments (limit ${MAX_ARGS})`);
|
|
124
|
+
for (const token of tokens) {
|
|
125
|
+
if (token.length > MAX_TOKEN) throw new Error("an argument is too long");
|
|
126
|
+
// Both would be truncated or mangled by the time they reached execve.
|
|
127
|
+
if (token.includes("\0") || /[\r\n]/.test(token))
|
|
128
|
+
throw new Error("arguments cannot span lines");
|
|
129
|
+
}
|
|
130
|
+
out.args = JSON.stringify(tokens);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (input.prompt !== undefined || !partial) {
|
|
134
|
+
const prompt = input.prompt ?? "";
|
|
135
|
+
if (prompt.length > MAX_PROMPT)
|
|
136
|
+
throw new Error(`prompt must be under ${MAX_PROMPT} characters`);
|
|
137
|
+
out.prompt = prompt;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A custom harness with no command would launch nothing at all, silently.
|
|
141
|
+
const harness = out.harness ?? input.harness;
|
|
142
|
+
if (harness === "custom" && out.command === null)
|
|
143
|
+
throw new Error("a custom harness needs a command");
|
|
144
|
+
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function listProfiles(db: Db, projectId: string): Profile[] {
|
|
149
|
+
return db
|
|
150
|
+
.select()
|
|
151
|
+
.from(profiles)
|
|
152
|
+
.where(eq(profiles.projectId, projectId))
|
|
153
|
+
.orderBy(asc(profiles.position), asc(profiles.createdAt))
|
|
154
|
+
.all()
|
|
155
|
+
.map(hydrate);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getProfile(db: Db, id: string): Profile | undefined {
|
|
159
|
+
const row = db.select().from(profiles).where(eq(profiles.id, id)).get();
|
|
160
|
+
return row ? hydrate(row) : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createProfile(
|
|
164
|
+
db: Db,
|
|
165
|
+
projectId: string,
|
|
166
|
+
input: ProfileInput,
|
|
167
|
+
): Profile {
|
|
168
|
+
const fields = validate(input, false);
|
|
169
|
+
const siblings = listProfiles(db, projectId);
|
|
170
|
+
const id = newId();
|
|
171
|
+
|
|
172
|
+
db.insert(profiles)
|
|
173
|
+
.values({
|
|
174
|
+
id,
|
|
175
|
+
projectId,
|
|
176
|
+
name: fields.name,
|
|
177
|
+
color: fields.color,
|
|
178
|
+
harness: fields.harness,
|
|
179
|
+
command: fields.command ?? null,
|
|
180
|
+
args: fields.args ?? "[]",
|
|
181
|
+
prompt: fields.prompt ?? "",
|
|
182
|
+
position: input.position ?? siblings.length,
|
|
183
|
+
createdAt: Date.now(),
|
|
184
|
+
})
|
|
185
|
+
.run();
|
|
186
|
+
|
|
187
|
+
const created = getProfile(db, id);
|
|
188
|
+
if (!created) throw new Error(`profile ${id} vanished after insert`);
|
|
189
|
+
return created;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function updateProfile(
|
|
193
|
+
db: Db,
|
|
194
|
+
id: string,
|
|
195
|
+
input: ProfileInput,
|
|
196
|
+
): Profile | undefined {
|
|
197
|
+
const current = getProfile(db, id);
|
|
198
|
+
if (!current) return undefined;
|
|
199
|
+
|
|
200
|
+
// Merged before validating so a partial update cannot land an invalid
|
|
201
|
+
// combination — clearing the command on an already-custom profile, say.
|
|
202
|
+
const fields = validate(
|
|
203
|
+
{ harness: current.harness, command: current.command, ...input },
|
|
204
|
+
true,
|
|
205
|
+
);
|
|
206
|
+
if (Object.keys(fields).length > 0) {
|
|
207
|
+
db.update(profiles).set(fields).where(eq(profiles.id, id)).run();
|
|
208
|
+
}
|
|
209
|
+
if (input.position !== undefined) {
|
|
210
|
+
db.update(profiles)
|
|
211
|
+
.set({ position: input.position })
|
|
212
|
+
.where(eq(profiles.id, id))
|
|
213
|
+
.run();
|
|
214
|
+
}
|
|
215
|
+
return getProfile(db, id);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Forgets a profile, leaving its windows running.
|
|
220
|
+
*
|
|
221
|
+
* Those windows are live sessions with real work in them. Deleting the
|
|
222
|
+
* definition of a role should not kill the session doing it — the window simply
|
|
223
|
+
* becomes an ordinary terminal.
|
|
224
|
+
*/
|
|
225
|
+
export function deleteProfile(db: Db, id: string): boolean {
|
|
226
|
+
const current = getProfile(db, id);
|
|
227
|
+
if (!current) return false;
|
|
228
|
+
db.update(windows)
|
|
229
|
+
.set({ profileId: null })
|
|
230
|
+
.where(eq(windows.profileId, id))
|
|
231
|
+
.run();
|
|
232
|
+
db.delete(profiles).where(eq(profiles.id, id)).run();
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function deleteProjectProfiles(db: Db, projectId: string): void {
|
|
237
|
+
db.delete(profiles).where(eq(profiles.projectId, projectId)).run();
|
|
238
|
+
}
|