@pablopunk/webr 0.1.1
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 +661 -0
- package/README.md +17 -0
- package/bin/webr.mjs +11 -0
- package/dist/client/_astro/App.BxlQhLhN.css +1 -0
- package/dist/client/_astro/App.CzxEZ_Dw.js +2 -0
- package/dist/client/_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js +1 -0
- package/dist/client/_astro/addon-fit.jgKqzwky.js +1 -0
- package/dist/client/_astro/addon-web-links.C9lyQRxI.js +1 -0
- package/dist/client/_astro/client.BDkzSRUN.js +1 -0
- package/dist/client/_astro/client.Bbg9OBwT.js +1 -0
- package/dist/client/_astro/client.CLSsMr2g.js +9 -0
- package/dist/client/_astro/jetbrains-mono.CFJaiWaY.woff2 +0 -0
- package/dist/client/_astro/symbols-nerd.CEdkKAK8.woff2 +0 -0
- package/dist/client/_astro/xterm.B-l5AIf6.js +7 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/brand-mark.png +0 -0
- package/dist/client/favicon-16.png +0 -0
- package/dist/client/favicon-32.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/icon-192.png +0 -0
- package/dist/client/icon-512.png +0 -0
- package/dist/client/icon-maskable-512.png +0 -0
- package/dist/client/manifest.webmanifest +30 -0
- package/dist/server/chunks/App_BmsGAwGf.mjs +4314 -0
- package/dist/server/chunks/_id__u0ueX6GO.mjs +34 -0
- package/dist/server/chunks/_tab__DL6wyr-j.mjs +35 -0
- package/dist/server/chunks/_virtual_astro_server-island-manifest_C1Q2srgE.mjs +5 -0
- package/dist/server/chunks/_virtual_astro_session-driver_DS5V7T-N.mjs +6 -0
- package/dist/server/chunks/compiler_CpXox_xS.mjs +35 -0
- package/dist/server/chunks/console_CucE1SwS.mjs +170 -0
- package/dist/server/chunks/errors_BgIp4W2B.mjs +383 -0
- package/dist/server/chunks/index_DyyPce_k.mjs +22 -0
- package/dist/server/chunks/new_G7XChx5p.mjs +33 -0
- package/dist/server/chunks/node_BlixNyYV.mjs +1816 -0
- package/dist/server/chunks/noop-entrypoint_Z3zFhrGC.mjs +4 -0
- package/dist/server/chunks/remote_BxuVLM0r.mjs +169 -0
- package/dist/server/chunks/server_DS45LC4q.mjs +2496 -0
- package/dist/server/chunks/settings_DLV2Js3e.mjs +31 -0
- package/dist/server/chunks/sharp_CgNvPCpY.mjs +152 -0
- package/dist/server/chunks/vnode-children_CRmaSs5G.mjs +24 -0
- package/dist/server/entry.mjs +5837 -0
- package/dist/server/virtual_astro_middleware.mjs +4 -0
- package/drizzle/0000_gateway.sql +13 -0
- package/drizzle/0001_device_sessions.sql +9 -0
- package/drizzle/0002_audit_events.sql +7 -0
- package/drizzle/meta/_journal.json +1 -0
- package/package.json +88 -0
- package/server/cli.ts +93 -0
- package/server/command.ts +9 -0
- package/server/entry.ts +3 -0
- package/server/herdr-check.ts +37 -0
- package/server/home.ts +4 -0
- package/server/log-file.ts +52 -0
- package/server/package-info.ts +6 -0
- package/server/plugin/config.ts +14 -0
- package/server/plugin/dev.ts +27 -0
- package/server/plugin/files.ts +10 -0
- package/server/plugin/herdr.ts +33 -0
- package/server/plugin/index.ts +68 -0
- package/server/plugin/probe.ts +17 -0
- package/server/plugin/stop.ts +39 -0
- package/server/port.ts +8 -0
- package/server/run.ts +35 -0
- package/server/ssr.ts +23 -0
- package/server/start.ts +5 -0
- package/server/update/hint.ts +26 -0
- package/server/update/index.ts +33 -0
- package/server/update/latest.ts +33 -0
- package/server/update/package-manager.ts +24 -0
- package/server/update/versions.ts +15 -0
- package/src/lib/avatars.ts +15 -0
- package/src/lib/launch.ts +16 -0
- package/src/lib/machines.ts +41 -0
- package/src/lib/mock-store.ts +82 -0
- package/src/lib/models.ts +139 -0
- package/src/lib/project-icons.ts +120 -0
- package/src/lib/terminal-split.ts +23 -0
- package/src/lib/uuid.ts +10 -0
- package/src/server/auth/access.ts +22 -0
- package/src/server/auth/audit.ts +15 -0
- package/src/server/auth/auth.ts +134 -0
- package/src/server/auth/pair-page.ts +85 -0
- package/src/server/auth/pairing.ts +91 -0
- package/src/server/auth/presence.ts +8 -0
- package/src/server/auth/sessions.ts +48 -0
- package/src/server/directories.ts +21 -0
- package/src/server/dist.ts +3 -0
- package/src/server/host.ts +166 -0
- package/src/server/perf-log.ts +23 -0
- package/src/server/protocol/deadlines.ts +24 -0
- package/src/server/protocol/native.ts +10 -0
- package/src/server/protocol/ndjson.ts +22 -0
- package/src/server/protocol/socket.ts +81 -0
- package/src/server/protocol/validate.ts +17 -0
- package/src/server/public-urls.ts +17 -0
- package/src/server/runtime/herdr-actions.ts +122 -0
- package/src/server/runtime/launch.ts +55 -0
- package/src/server/runtime/manager.ts +187 -0
- package/src/server/runtime/reconcile.ts +51 -0
- package/src/server/runtime/supervisor.ts +92 -0
- package/src/server/runtime/target.ts +37 -0
- package/src/server/runtime/workspace.ts +24 -0
- package/src/server/storage/database.ts +88 -0
- package/src/server/storage/drizzle-node-sqlite.ts +18 -0
- package/src/server/storage/node-sqlite-client.ts +44 -0
- package/src/server/storage/schema.ts +25 -0
- package/src/server/tailscale-host.ts +17 -0
- package/src/server/terminal/cli.ts +44 -0
- package/src/server/terminal/hub.ts +140 -0
- package/src/server/terminal/writer.ts +38 -0
- package/src/server/transport/herdr-target.ts +148 -0
- package/src/server/transport/icons.ts +65 -0
- package/src/server/transport/identity.ts +5 -0
- package/src/server/transport/local-session.ts +65 -0
- package/src/server/transport/model-discovery.ts +18 -0
- package/src/server/transport/native-locations.ts +17 -0
- package/src/server/transport/process.ts +21 -0
- package/src/server/transport/registry.ts +27 -0
- package/src/server/transport/ssh.ts +47 -0
- package/src/server/uploads.ts +40 -0
- package/src/server/websockets.ts +62 -0
- package/src/shared/agent-kinds.ts +4 -0
- package/src/shared/frame.ts +38 -0
- package/src/shared/projects.ts +1 -0
- package/src/shared/retry.ts +1 -0
- package/src/shared/runtime.ts +33 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { installedEntry } from '../entry';
|
|
3
|
+
import { packageInfo } from '../package-info';
|
|
4
|
+
import { fetchLatestVersion, isCacheFresh, readCachedLatest, writeCachedLatest } from './latest';
|
|
5
|
+
import { isNewerVersion } from './versions';
|
|
6
|
+
|
|
7
|
+
export const updateCheckDisabled = (env: NodeJS.ProcessEnv = process.env) => !!env.WEBR_NO_UPDATE_CHECK && env.WEBR_NO_UPDATE_CHECK !== '0';
|
|
8
|
+
|
|
9
|
+
const availableMessage = (latest: string) => `A new version of Webr is available: ${latest}. Run "webr update" to install it.`;
|
|
10
|
+
|
|
11
|
+
export async function refreshLatestVersionCache(env: NodeJS.ProcessEnv = process.env) {
|
|
12
|
+
const { name, version } = packageInfo();
|
|
13
|
+
const latest = await fetchLatestVersion(name).catch(() => readCachedLatest(env)?.latest ?? version);
|
|
14
|
+
writeCachedLatest(latest, env);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const refreshInBackground = () => {
|
|
18
|
+
spawn(process.execPath, [installedEntry(), 'refresh-update-cache'], { detached: true, stdio: 'ignore', windowsHide: true, env: process.env }).unref();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function updateHint(env: NodeJS.ProcessEnv = process.env, refresh: () => void = refreshInBackground, now = Date.now()): string | undefined {
|
|
22
|
+
if (updateCheckDisabled(env)) return undefined;
|
|
23
|
+
const cached = readCachedLatest(env);
|
|
24
|
+
if (!isCacheFresh(cached, now)) refresh();
|
|
25
|
+
return cached && isNewerVersion(cached.latest, packageInfo().version) ? availableMessage(cached.latest) : undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { packageInfo } from '../package-info';
|
|
3
|
+
import { fetchLatestVersion, writeCachedLatest } from './latest';
|
|
4
|
+
import { detectPackageManager, globalInstallCommand, installedPackagePath, type InstallCommand } from './package-manager';
|
|
5
|
+
import { isNewerVersion } from './versions';
|
|
6
|
+
|
|
7
|
+
export type UpdateEnvironment = {
|
|
8
|
+
installPath: () => string;
|
|
9
|
+
latestVersion: (packageName: string) => Promise<string>;
|
|
10
|
+
install: (command: InstallCommand) => boolean;
|
|
11
|
+
restartPlugin: () => Promise<string[] | undefined>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const installInTerminal = ({ command, args }: InstallCommand) => spawnSync(command, args, { stdio: 'inherit', shell: process.platform === 'win32' }).status === 0;
|
|
15
|
+
|
|
16
|
+
export const realUpdateEnvironment = (restartPlugin: UpdateEnvironment['restartPlugin']): UpdateEnvironment => ({
|
|
17
|
+
installPath: installedPackagePath,
|
|
18
|
+
latestVersion: fetchLatestVersion,
|
|
19
|
+
install: installInTerminal,
|
|
20
|
+
restartPlugin,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export async function runUpdate(environment: UpdateEnvironment) {
|
|
24
|
+
const { name, version } = packageInfo();
|
|
25
|
+
const manager = detectPackageManager(environment.installPath());
|
|
26
|
+
if (!manager) throw new Error('This copy of Webr was not installed globally, so it cannot update itself. If it is a git checkout, pull and rebuild it.');
|
|
27
|
+
const latest = await environment.latestVersion(name);
|
|
28
|
+
writeCachedLatest(latest);
|
|
29
|
+
if (!isNewerVersion(latest, version)) return [`Webr ${version} is the latest version.`];
|
|
30
|
+
const command = globalInstallCommand(manager, name);
|
|
31
|
+
if (!environment.install(command)) throw new Error(`${manager} could not install ${name}@${latest}. Run it yourself to see why: ${command.command} ${command.args.join(' ')}`);
|
|
32
|
+
return [`Updated Webr ${version} → ${latest}.`, ...((await environment.restartPlugin()) ?? ['Restart Herdr to use the new version.'])];
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { webrHome } from '../home';
|
|
4
|
+
|
|
5
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
7
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
type Fetch = typeof fetch;
|
|
10
|
+
type CachedLatest = { checkedAt: number; latest: string };
|
|
11
|
+
|
|
12
|
+
const cachePath = (env: NodeJS.ProcessEnv) => join(webrHome(env), 'update-check.json');
|
|
13
|
+
const registryUrl = (packageName: string) => `${REGISTRY}/${packageName.replace('/', '%2F')}/latest`;
|
|
14
|
+
|
|
15
|
+
export async function fetchLatestVersion(packageName: string, request: Fetch = fetch): Promise<string> {
|
|
16
|
+
const response = await request(registryUrl(packageName), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
17
|
+
if (!response.ok) throw new Error(`The npm registry answered ${response.status}.`);
|
|
18
|
+
const { version } = await response.json() as { version?: unknown };
|
|
19
|
+
if (typeof version !== 'string') throw new Error('The npm registry sent no version.');
|
|
20
|
+
return version;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readCachedLatest(env: NodeJS.ProcessEnv = process.env): CachedLatest | undefined {
|
|
24
|
+
try { return JSON.parse(readFileSync(cachePath(env), 'utf8')) as CachedLatest; } catch { return undefined; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeCachedLatest(latest: string, env: NodeJS.ProcessEnv = process.env, now = Date.now()) {
|
|
28
|
+
const path = cachePath(env);
|
|
29
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
30
|
+
writeFileSync(path, JSON.stringify({ checkedAt: now, latest } satisfies CachedLatest));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const isCacheFresh = (cached: CachedLatest | undefined, now = Date.now()): cached is CachedLatest => !!cached && now - cached.checkedAt < CACHE_TTL_MS;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export type PackageManager = 'npm' | 'pnpm' | 'bun';
|
|
5
|
+
export type InstallCommand = { command: string; args: string[] };
|
|
6
|
+
|
|
7
|
+
const isInsideNodeModules = (path: string) => /[\\/]node_modules[\\/]/.test(path);
|
|
8
|
+
const isBunInstall = (path: string) => /[\\/]\.bun[\\/]/.test(path);
|
|
9
|
+
const isPnpmInstall = (path: string) => /[\\/]pnpm[\\/]/.test(path) || /[\\/]\.pnpm[\\/]/.test(path);
|
|
10
|
+
|
|
11
|
+
export function detectPackageManager(installPath: string): PackageManager | undefined {
|
|
12
|
+
if (!isInsideNodeModules(installPath)) return undefined;
|
|
13
|
+
if (isBunInstall(installPath)) return 'bun';
|
|
14
|
+
return isPnpmInstall(installPath) ? 'pnpm' : 'npm';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const installedPackagePath = () => realpathSync(fileURLToPath(new URL('../../package.json', import.meta.url)));
|
|
18
|
+
|
|
19
|
+
export function globalInstallCommand(manager: PackageManager, packageName: string): InstallCommand {
|
|
20
|
+
const target = `${packageName}@latest`;
|
|
21
|
+
if (manager === 'pnpm') return { command: 'pnpm', args: ['add', '-g', target] };
|
|
22
|
+
if (manager === 'bun') return { command: 'bun', args: ['add', '-g', target] };
|
|
23
|
+
return { command: 'npm', args: ['install', '-g', target] };
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type Version = { core: number[]; prerelease: boolean };
|
|
2
|
+
|
|
3
|
+
const parse = (text: string): Version | undefined => {
|
|
4
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(-[^+]+)?/.exec(text.trim());
|
|
5
|
+
return match ? { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: !!match[4] } : undefined;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function isNewerVersion(candidate: string, current: string) {
|
|
9
|
+
const next = parse(candidate);
|
|
10
|
+
const installed = parse(current);
|
|
11
|
+
if (!next || !installed) return false;
|
|
12
|
+
const differing = next.core.findIndex((part, index) => part !== installed.core[index]);
|
|
13
|
+
if (differing >= 0) return next.core[differing] > installed.core[differing];
|
|
14
|
+
return installed.prerelease && !next.prerelease;
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { botAvatarTypes } from 'bot-avatars';
|
|
2
|
+
import type { Thread } from './models';
|
|
3
|
+
|
|
4
|
+
const GOLDEN_ANGLE_DEGREES = 137.508;
|
|
5
|
+
|
|
6
|
+
const hueFarFromThePreviousThread = (index: number) => ((index * GOLDEN_ANGLE_DEGREES) % 360).toFixed(3);
|
|
7
|
+
|
|
8
|
+
export function avatarForThread(thread: Thread) {
|
|
9
|
+
const index = thread.avatarIndex;
|
|
10
|
+
return {
|
|
11
|
+
type: botAvatarTypes[index % botAvatarTypes.length],
|
|
12
|
+
color: `hsl(${hueFarFromThePreviousThread(index)} 72% 58%)`,
|
|
13
|
+
seed: (index * 0.61803398875) % 1,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Thread } from './models';
|
|
2
|
+
|
|
3
|
+
export const launchSteps = [
|
|
4
|
+
{ id: 'validate', label: 'Checking the machine', active: 'Checking the machine…' },
|
|
5
|
+
{ id: 'checkout', label: 'Creating the worktree', active: 'Creating the worktree…' },
|
|
6
|
+
{ id: 'start', label: 'Starting the agent', active: 'Starting the agent…' },
|
|
7
|
+
{ id: 'prompt', label: 'Sending your prompt', active: 'Sending your prompt…' },
|
|
8
|
+
] as const;
|
|
9
|
+
const RECENT_FAILURE_MS = 10 * 60_000;
|
|
10
|
+
|
|
11
|
+
export const launchStepIndex = (step: string) => step === 'checkout_complete' ? 2 : Math.max(0, launchSteps.findIndex((item) => item.id === step));
|
|
12
|
+
export const isLaunching = (thread: Thread) => ['pending', 'running'].includes(thread.operation?.state ?? '') && !thread.panes.some((pane) => pane.kind === 'agent');
|
|
13
|
+
export const launchFailed = (thread: Thread) => ['failed', 'unknown'].includes(thread.operation?.state ?? '') && !thread.panes.some((pane) => pane.kind === 'agent');
|
|
14
|
+
export const launchLabel = (thread: Thread) => launchSteps[launchStepIndex(thread.operation?.step ?? 'validate')].active;
|
|
15
|
+
const failedRecently = (thread: Thread, now: number) => now - Date.parse(thread.updatedAt) < RECENT_FAILURE_MS;
|
|
16
|
+
export const isListedThread = (thread: Thread, now = Date.now()) => thread.panes.some((pane) => pane.kind === 'agent') || isLaunching(thread) || launchFailed(thread) && failedRecently(thread, now);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type MachineHarness = { id: string; name: string; models: string[]; customModels?: boolean; launchEnabled?: boolean; reason?: string };
|
|
2
|
+
export type Machine = {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
connected: boolean;
|
|
6
|
+
session: string;
|
|
7
|
+
projectPaths: Record<string, string>;
|
|
8
|
+
harnesses: MachineHarness[];
|
|
9
|
+
error?: string;
|
|
10
|
+
writable?: boolean;
|
|
11
|
+
configVersion?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const machines: Machine[] = [
|
|
15
|
+
{
|
|
16
|
+
id: 'local', name: 'Local', connected: true, session: 'default',
|
|
17
|
+
projectPaths: { herdr: '~/src/herdr', maze: '~/src/maze/monorepo', spotifin: '~/src/spotifin' },
|
|
18
|
+
harnesses: [
|
|
19
|
+
{ id: 'claude', name: 'Claude Code', models: ['Default', 'sonnet', 'opus'] },
|
|
20
|
+
{ id: 'codex', name: 'Codex', models: ['Default'] },
|
|
21
|
+
{ id: 'opencode', name: 'OpenCode', models: ['Default'] },
|
|
22
|
+
{ id: 'pi', name: 'Pi', models: ['Default'] },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'demo-build', name: 'Build machine (demo)', connected: true, session: 'agents',
|
|
27
|
+
projectPaths: { herdr: '/srv/herdr', spotifin: '/srv/spotifin' },
|
|
28
|
+
harnesses: [
|
|
29
|
+
{ id: 'opencode', name: 'OpenCode', models: ['Default', 'demo/remote-model'] },
|
|
30
|
+
{ id: 'pi', name: 'Pi', models: ['Default', 'demo/remote-model'] },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'demo-offline', name: 'Offline machine (demo)', connected: false, session: 'default',
|
|
35
|
+
projectPaths: {}, harnesses: [],
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function findMachine(id: string): Machine | undefined {
|
|
40
|
+
return machines.find((machine) => machine.id === id);
|
|
41
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { exampleThreads, harnessName, projects, type Thread } from './models';
|
|
5
|
+
import { findMachine } from './machines';
|
|
6
|
+
|
|
7
|
+
const dataFile = join(process.cwd(), '.data', 'threads.json');
|
|
8
|
+
let pendingWrite: Promise<unknown> = Promise.resolve();
|
|
9
|
+
const requireFixtureMode = () => { if (process.env.NODE_ENV !== 'test' && !(process.env.WEBR_FIXTURES === '1' && process.env.NODE_ENV !== 'production')) throw new Error('Mock data requires explicit fixture mode'); };
|
|
10
|
+
|
|
11
|
+
async function savedThreads(): Promise<Thread[]> {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(await readFile(dataFile, 'utf8')) as Thread[];
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function listThreads(): Promise<Thread[]> {
|
|
21
|
+
requireFixtureMode();
|
|
22
|
+
await pendingWrite;
|
|
23
|
+
const examples = exampleThreads();
|
|
24
|
+
const saved = (await savedThreads()).map((thread, index) => ({
|
|
25
|
+
...thread, avatarIndex: thread.avatarIndex ?? examples.length + index,
|
|
26
|
+
prompt: thread.prompt ?? thread.title, model: thread.model ?? 'Default',
|
|
27
|
+
machineId: thread.machineId ?? 'local',
|
|
28
|
+
}));
|
|
29
|
+
return [...examples, ...saved]
|
|
30
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function findThread(id: string): Promise<Thread | undefined> {
|
|
34
|
+
return (await listThreads()).find((thread) => thread.id === id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createThread(input: {
|
|
38
|
+
projectId: string;
|
|
39
|
+
machineId: string;
|
|
40
|
+
prompt: string;
|
|
41
|
+
agent: string;
|
|
42
|
+
model: string;
|
|
43
|
+
worktree: boolean;
|
|
44
|
+
}): Promise<Thread> {
|
|
45
|
+
requireFixtureMode();
|
|
46
|
+
const operation = pendingWrite.then(async () => {
|
|
47
|
+
const machine = findMachine(input.machineId);
|
|
48
|
+
if (!machine?.connected) throw new Error('Machine is not connected');
|
|
49
|
+
if (!projects.some((project) => project.id === input.projectId)) throw new Error('Unknown project');
|
|
50
|
+
if (!Object.hasOwn(machine.projectPaths, input.projectId)) throw new Error('Project is not available on this machine');
|
|
51
|
+
if (!machine.harnesses.some((harness) => harness.id === input.agent.trim())) throw new Error('Harness is not available on this machine');
|
|
52
|
+
const id = randomUUID().slice(0, 8);
|
|
53
|
+
const prompt = input.prompt.trim();
|
|
54
|
+
if (!prompt || prompt.length > 8000 || !input.agent.trim() || !input.model.trim()) throw new Error('Invalid thread details');
|
|
55
|
+
const name = prompt.split('\n').find((line) => line.trim())?.trim().replace(/\s+/g, ' ').slice(0, 90) ?? '';
|
|
56
|
+
if (!name) throw new Error('Enter a prompt');
|
|
57
|
+
const branch = input.worktree
|
|
58
|
+
? `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 32)}-${id}`
|
|
59
|
+
: 'main';
|
|
60
|
+
const threads = await savedThreads();
|
|
61
|
+
const thread: Thread = {
|
|
62
|
+
id, avatarIndex: exampleThreads().length + threads.length,
|
|
63
|
+
projectId: input.projectId, machineId: machine.id, title: name, prompt, agent: input.agent.trim(), model: input.model.trim(),
|
|
64
|
+
status: 'idle', updatedAt: new Date().toISOString(), branch,
|
|
65
|
+
worktree: input.worktree, session: machine.session, tabId: `mock:t${id}`,
|
|
66
|
+
panes: [{ id: `mock:p${id}`, title: harnessName(input.agent.trim()), kind: 'agent', lines: [
|
|
67
|
+
`Machine: ${machine.name} · ${machine.projectPaths[input.projectId]}`,
|
|
68
|
+
`${harnessName(input.agent.trim())} · ${input.model.trim()}`, 'Prompt:',
|
|
69
|
+
...prompt.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '').split('\n'), '',
|
|
70
|
+
'This is a design prototype. The Herdr connection is not active yet.',
|
|
71
|
+
'Type in this terminal to try the layout.', '', '$',
|
|
72
|
+
] }],
|
|
73
|
+
};
|
|
74
|
+
await mkdir(join(process.cwd(), '.data'), { recursive: true });
|
|
75
|
+
const tempFile = `${dataFile}.${id}.tmp`;
|
|
76
|
+
await writeFile(tempFile, JSON.stringify([...threads, thread], null, 2));
|
|
77
|
+
await rename(tempFile, dataFile);
|
|
78
|
+
return thread;
|
|
79
|
+
});
|
|
80
|
+
pendingWrite = operation.catch(() => {});
|
|
81
|
+
return operation;
|
|
82
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export type AgentStatus = 'working' | 'blocked' | 'done' | 'idle' | 'unknown';
|
|
2
|
+
export const harnessNames: Record<string, string> = {
|
|
3
|
+
claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', pi: 'Pi',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function harnessName(agent: string): string {
|
|
7
|
+
return Object.hasOwn(harnessNames, agent) ? harnessNames[agent] : agent;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type Project = {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
path: string;
|
|
14
|
+
color: string;
|
|
15
|
+
initial: string;
|
|
16
|
+
iconUrl?: string;
|
|
17
|
+
machineId?: string;
|
|
18
|
+
localId?: string;
|
|
19
|
+
logicalId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type Pane = {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
kind: 'agent' | 'shell';
|
|
26
|
+
lines?: string[];
|
|
27
|
+
terminalId?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type Thread = {
|
|
31
|
+
id: string;
|
|
32
|
+
avatarIndex: number;
|
|
33
|
+
projectId: string;
|
|
34
|
+
machineId: string;
|
|
35
|
+
title: string;
|
|
36
|
+
prompt: string;
|
|
37
|
+
agent: string;
|
|
38
|
+
model: string;
|
|
39
|
+
status: AgentStatus;
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
branch: string;
|
|
42
|
+
worktree: boolean;
|
|
43
|
+
ownsWorktree?: boolean;
|
|
44
|
+
session: string;
|
|
45
|
+
tabId: string;
|
|
46
|
+
panes: Pane[];
|
|
47
|
+
bindingState?: 'attached' | 'detached' | 'pending';
|
|
48
|
+
operation?: { id: string; state: string; step: string };
|
|
49
|
+
bindingFingerprint?: string;
|
|
50
|
+
bindingConfigVersion?: number;
|
|
51
|
+
semanticSignature?: string;
|
|
52
|
+
archivedAt?: string;
|
|
53
|
+
terminalHidden?: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const projects: Project[] = [
|
|
57
|
+
{ id: 'herdr', name: 'herdr', path: '~/src/herdr', color: '#9b83df', initial: 'H' },
|
|
58
|
+
{ id: 'maze', name: 'maze-monorepo', path: '~/src/maze/monorepo', color: '#e6a76d', initial: 'M' },
|
|
59
|
+
{ id: 'spotifin', name: 'spotifin', path: '~/src/spotifin', color: '#70b6a1', initial: 'S' },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
|
63
|
+
|
|
64
|
+
export function exampleThreads(): Thread[] {
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
id: 'terminal-web-bridge', avatarIndex: 0, projectId: 'herdr', machineId: 'local', title: 'Build the web terminal bridge',
|
|
68
|
+
prompt: 'Build the web terminal bridge', agent: 'claude', model: 'Default', status: 'working', updatedAt: at(2), branch: 'web-terminal-bridge',
|
|
69
|
+
worktree: true, session: 'default', tabId: 'w1:t2',
|
|
70
|
+
panes: [
|
|
71
|
+
{ id: 'w1:p3', title: 'Claude Code', kind: 'agent', lines: [
|
|
72
|
+
'$ claude', '✳ Building the web terminal bridge', '',
|
|
73
|
+
'I found the terminal session control API. Each pane can stream',
|
|
74
|
+
'rendered ANSI frames and accept input, resize, and mouse events.', '',
|
|
75
|
+
'▸ Reading src/client/terminal_sessions.rs', '▸ Checking the frame protocol', '',
|
|
76
|
+
'Working…',
|
|
77
|
+
] },
|
|
78
|
+
{ id: 'w1:p4', title: 'Shell', kind: 'shell', lines: [
|
|
79
|
+
'$ git status --short', ' M src/client/terminal_sessions.rs',
|
|
80
|
+
'$ cargo test terminal_session', '',
|
|
81
|
+
'running 18 tests', 'test terminal::frame_encoding ... ok',
|
|
82
|
+
'test terminal::controller_lifecycle ... ok', '',
|
|
83
|
+
'test result: ok. 18 passed; 0 failed', '$',
|
|
84
|
+
] },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'review-socket-api', avatarIndex: 1, projectId: 'herdr', machineId: 'local', title: 'Review socket API changes',
|
|
89
|
+
prompt: 'Review socket API changes', agent: 'codex', model: 'Default', status: 'blocked', updatedAt: at(14), branch: 'socket-api-review',
|
|
90
|
+
worktree: true, session: 'default', tabId: 'w1:t3',
|
|
91
|
+
panes: [{ id: 'w1:p5', title: 'Codex', kind: 'agent', lines: [
|
|
92
|
+
'$ codex', 'Review complete. One change needs your approval.', '',
|
|
93
|
+
'The new endpoint can replace the active controller.',
|
|
94
|
+
'Allow this change?', '', '> Waiting for input',
|
|
95
|
+
] }],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'sidebar-interactions', avatarIndex: 2, projectId: 'herdr', machineId: 'local', title: 'Polish sidebar interactions',
|
|
99
|
+
prompt: 'Polish sidebar interactions', agent: 'opencode', model: 'Default', status: 'done', updatedAt: at(82), branch: 'sidebar-polish',
|
|
100
|
+
worktree: true, session: 'default', tabId: 'w1:t4',
|
|
101
|
+
panes: [{ id: 'w1:p6', title: 'OpenCode', kind: 'agent', lines: [
|
|
102
|
+
'$ opencode', 'Done. The sidebar now keeps its scroll position when',
|
|
103
|
+
'thread status changes. Ready for review.', '', '✓ 12 tests passed', '$',
|
|
104
|
+
] }],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'fix-editor-loading', avatarIndex: 3, projectId: 'maze', machineId: 'local', title: 'Fix editor loading state',
|
|
108
|
+
prompt: 'Fix editor loading state', agent: 'claude', model: 'Default', status: 'working', updatedAt: at(7), branch: 'fix-editor-loading',
|
|
109
|
+
worktree: true, session: 'default', tabId: 'w2:t1',
|
|
110
|
+
panes: [{ id: 'w2:p1', title: 'Claude Code', kind: 'agent', lines: [
|
|
111
|
+
'$ claude', '✳ Investigating the loading state', '',
|
|
112
|
+
'The editor starts a second request while the first one is still',
|
|
113
|
+
'active. I am tracing the state transition now.', '', 'Working…',
|
|
114
|
+
] }],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'coverflow-motion', avatarIndex: 4, projectId: 'spotifin', machineId: 'local', title: 'Tune coverflow motion',
|
|
118
|
+
prompt: 'Tune coverflow motion', agent: 'pi', model: 'Default', status: 'idle', updatedAt: at(1440), branch: 'coverflow-motion',
|
|
119
|
+
worktree: false, session: 'default', tabId: 'w3:t1',
|
|
120
|
+
panes: [{ id: 'w3:p1', title: 'Pi', kind: 'agent', lines: [
|
|
121
|
+
'$ pi', 'The transition timing is now 280ms. All changes are ready',
|
|
122
|
+
'for you to inspect.', '', 'Ready for input',
|
|
123
|
+
] }],
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function relativeTime(value: string): string {
|
|
129
|
+
const minutes = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 60_000));
|
|
130
|
+
if (minutes < 1) return 'now';
|
|
131
|
+
if (minutes < 60) return `${minutes}m`;
|
|
132
|
+
if (minutes < 1440) return `${Math.floor(minutes / 60)}h`;
|
|
133
|
+
return `${Math.floor(minutes / 1440)}d`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function timeAgo(value: string): string {
|
|
137
|
+
const relative = relativeTime(value);
|
|
138
|
+
return relative === 'now' ? 'just now' : `${relative} ago`;
|
|
139
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
4
|
+
import { projects, type Project } from './models';
|
|
5
|
+
|
|
6
|
+
const maxBytes = 1_000_000;
|
|
7
|
+
const cacheForMs = 60_000;
|
|
8
|
+
const contentTypes: Record<string, string> = {
|
|
9
|
+
'.png': 'image/png', '.ico': 'image/x-icon', '.svg': 'image/svg+xml',
|
|
10
|
+
'.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ProjectIcon = { bytes: Uint8Array<ArrayBuffer>; contentType: string };
|
|
14
|
+
const cache = new Map<string, { until: number; icon: Promise<ProjectIcon | undefined> }>();
|
|
15
|
+
|
|
16
|
+
function unavailable(error: unknown): boolean {
|
|
17
|
+
return ['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes((error as NodeJS.ErrnoException).code ?? '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rank(name: string, generic: boolean): number {
|
|
21
|
+
const base = name.slice(0, -extname(name).length).toLowerCase();
|
|
22
|
+
const extension = extname(name).toLowerCase();
|
|
23
|
+
if (!contentTypes[extension]) return Infinity;
|
|
24
|
+
if (generic) return extension === '.png' ? 0 : Infinity;
|
|
25
|
+
if (base === 'favicon') return 0;
|
|
26
|
+
if (base.startsWith('favicon-')) return 1;
|
|
27
|
+
if (base.startsWith('apple-touch-icon')) return 2;
|
|
28
|
+
if (base === 'icon' || base === 'app-icon') return 3;
|
|
29
|
+
if (base.startsWith('icon-') || base === 'logo' || base === 'brand' || base === 'mark') return 4;
|
|
30
|
+
if (base.startsWith('logo-') || base.startsWith('brand-')) return 5;
|
|
31
|
+
return Infinity;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function withinRoot(root: string, path: string): boolean {
|
|
35
|
+
const part = relative(root, path);
|
|
36
|
+
return part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function squarePng(bytes: Uint8Array): boolean {
|
|
40
|
+
if (bytes.length < 24 || bytes[0] !== 137 || bytes[1] !== 80 || bytes[2] !== 78 || bytes[3] !== 71) return false;
|
|
41
|
+
const size = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
42
|
+
const width = size.getUint32(16);
|
|
43
|
+
const height = size.getUint32(20);
|
|
44
|
+
return width >= 16 && width <= 512 && height >= 16 && height <= 512 && width / height >= 0.8 && width / height <= 1.25;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function iconInDirectory(root: string, directory: string, generic = false): Promise<ProjectIcon | undefined> {
|
|
48
|
+
try {
|
|
49
|
+
const actualDirectory = await realpath(directory);
|
|
50
|
+
if (!withinRoot(root, actualDirectory)) return;
|
|
51
|
+
const entries = (await readdir(actualDirectory, { withFileTypes: true }))
|
|
52
|
+
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && Number.isFinite(rank(entry.name, generic)))
|
|
53
|
+
.sort((a, b) => rank(a.name, generic) - rank(b.name, generic) || a.name.localeCompare(b.name))
|
|
54
|
+
.slice(0, generic ? 12 : undefined);
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
try {
|
|
57
|
+
const path = await realpath(join(actualDirectory, entry.name));
|
|
58
|
+
if (!withinRoot(root, path)) continue;
|
|
59
|
+
const info = await stat(path);
|
|
60
|
+
if (!info.isFile() || info.size === 0 || info.size > maxBytes) continue;
|
|
61
|
+
const bytes = new Uint8Array(await readFile(path));
|
|
62
|
+
if (generic && !squarePng(bytes)) continue;
|
|
63
|
+
return { bytes, contentType: contentTypes[extname(entry.name).toLowerCase()] };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (!unavailable(error)) throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!unavailable(error)) throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function discover(project: Project): Promise<ProjectIcon | undefined> {
|
|
74
|
+
const directory = project.path.startsWith('~/') ? join(homedir(), project.path.slice(2)) : resolve(project.path);
|
|
75
|
+
let root: string;
|
|
76
|
+
try { root = await realpath(directory); } catch (error) {
|
|
77
|
+
if (unavailable(error)) return;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let apps = [] as string[];
|
|
82
|
+
try {
|
|
83
|
+
const appsDirectory = await realpath(join(root, 'apps'));
|
|
84
|
+
if (withinRoot(root, appsDirectory)) {
|
|
85
|
+
apps = (await readdir(appsDirectory, { withFileTypes: true }))
|
|
86
|
+
.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
|
|
87
|
+
.sort((a, b) => Number(!a.includes(project.id)) - Number(!b.includes(project.id)) || a.localeCompare(b))
|
|
88
|
+
.slice(0, 8);
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!unavailable(error)) throw error;
|
|
92
|
+
}
|
|
93
|
+
const directories = [
|
|
94
|
+
...['', 'public', 'static', 'assets', 'web', 'web/public', 'app', 'app/public', 'src/assets', 'src/app', 'frontend/public'].map((path) => join(root, path)),
|
|
95
|
+
...apps.flatMap((app) => ['public', 'src/assets', 'assets'].map((path) => join(root, 'apps', app, path))),
|
|
96
|
+
];
|
|
97
|
+
for (const generic of [false, true]) {
|
|
98
|
+
for (const directory of directories) {
|
|
99
|
+
const icon = await iconInDirectory(root, directory, generic);
|
|
100
|
+
if (icon) return icon;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function findProjectIcon(project: Project): Promise<ProjectIcon | undefined> {
|
|
106
|
+
const cached = cache.get(project.id);
|
|
107
|
+
if (cached && cached.until > Date.now()) return cached.icon;
|
|
108
|
+
const icon = discover(project).catch((error) => {
|
|
109
|
+
cache.delete(project.id);
|
|
110
|
+
throw error;
|
|
111
|
+
});
|
|
112
|
+
cache.set(project.id, { until: Date.now() + cacheForMs, icon });
|
|
113
|
+
return icon;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function projectsWithIcons(): Promise<Project[]> {
|
|
117
|
+
return Promise.all(projects.map(async (project) => ({
|
|
118
|
+
...project, iconUrl: await findProjectIcon(project) ? `/api/projects/${encodeURIComponent(project.id)}/icon` : undefined,
|
|
119
|
+
})));
|
|
120
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Pane, Thread } from './models';
|
|
2
|
+
import type { Layout, Rect } from '../shared/runtime';
|
|
3
|
+
|
|
4
|
+
export type PeekSide = 'right' | 'bottom';
|
|
5
|
+
export const threadAgent = (thread: Thread): Pane | undefined => thread.panes.find((pane) => pane.kind === 'agent') ?? thread.panes[0];
|
|
6
|
+
export const threadShell = (thread: Thread): Pane | undefined => { const main = threadAgent(thread); return thread.panes.find((pane) => pane.kind === 'shell' && pane.id !== main?.id); };
|
|
7
|
+
export const peekSide = (shell: Rect, area: Rect): PeekSide => shell.x > area.x && shell.y === area.y ? 'right' : 'bottom';
|
|
8
|
+
export const terminalDirection = (side: PeekSide) => side === 'right' ? 'right' as const : 'down' as const;
|
|
9
|
+
|
|
10
|
+
export function hiddenTerminalLayout(thread: Thread, layout: Layout) {
|
|
11
|
+
const shell = threadShell(thread); const agent = threadAgent(thread);
|
|
12
|
+
const shellRect = shell && layout.panes.find((rect) => rect.paneId === shell.id)?.rect;
|
|
13
|
+
const otherPanes = thread.panes.filter((pane) => pane.id !== shell?.id && pane.id !== agent?.id);
|
|
14
|
+
if (!thread.terminalHidden || !shell || !agent || !shellRect || otherPanes.length) return undefined;
|
|
15
|
+
return { shell, agent, side: peekSide(shellRect, layout.area) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function currentTerminalDirection(thread: Thread, layouts: Layout[], narrow: boolean) {
|
|
19
|
+
const shell = threadShell(thread);
|
|
20
|
+
const layout = layouts.find((layout) => layout.panes.some((rect) => rect.paneId === shell?.id));
|
|
21
|
+
const rect = layout?.panes.find((rect) => rect.paneId === shell?.id)?.rect;
|
|
22
|
+
return rect && layout ? terminalDirection(peekSide(rect, layout.area)) : narrow ? 'down' : 'right';
|
|
23
|
+
}
|
package/src/lib/uuid.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const hex = (bytes: Uint8Array) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
2
|
+
|
|
3
|
+
export function uuid() {
|
|
4
|
+
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
|
5
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
6
|
+
bytes[6] = bytes[6] & 0x0f | 0x40;
|
|
7
|
+
bytes[8] = bytes[8] & 0x3f | 0x80;
|
|
8
|
+
const text = hex(bytes);
|
|
9
|
+
return `${text.slice(0, 8)}-${text.slice(8, 12)}-${text.slice(12, 16)}-${text.slice(16, 20)}-${text.slice(20)}`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
2
|
+
|
|
3
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
4
|
+
const FORWARDING_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'forwarded', 'via'];
|
|
5
|
+
|
|
6
|
+
const hostnameOf = (host: string | undefined) => { try { return new URL(`http://${host}`).hostname; } catch { return undefined; } };
|
|
7
|
+
const isLoopbackAddress = (address: string | undefined) => !!address && (address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.'));
|
|
8
|
+
const isForwarded = (headers: IncomingHttpHeaders) => FORWARDING_HEADERS.some((name) => name in headers) || Object.keys(headers).some((name) => name.startsWith('tailscale-'));
|
|
9
|
+
const isIpLiteral = (hostname: string) => hostname.startsWith('[') || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
|
|
10
|
+
const isNameControlledByNetwork = (hostname: string) => hostname.endsWith('.local') || hostname.endsWith('.ts.net');
|
|
11
|
+
|
|
12
|
+
export const sourceAddress = (address: string) => address.replace(/^::ffff:/, '');
|
|
13
|
+
export const isLoopbackHost = (host: string | undefined) => LOOPBACK_HOSTNAMES.has(hostnameOf(host) ?? '');
|
|
14
|
+
|
|
15
|
+
export function isLocalRequest(request: { headers: IncomingHttpHeaders; socket: { remoteAddress?: string } }) {
|
|
16
|
+
return isLoopbackAddress(request.socket.remoteAddress) && isLoopbackHost(request.headers.host) && !isForwarded(request.headers);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isTrustedHost(host: string | undefined, configuredHost: string) {
|
|
20
|
+
const hostname = hostnameOf(host);
|
|
21
|
+
return !!host && !!hostname && (host === configuredHost || LOOPBACK_HOSTNAMES.has(hostname) || isIpLiteral(hostname) || isNameControlledByNetwork(hostname));
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MetadataDatabase } from '../storage/database';
|
|
2
|
+
|
|
3
|
+
export type AuditKind = 'pair_requested' | 'pair_approved' | 'pair_denied' | 'invite_created' | 'invite_redeemed' | 'device_revoked' | 'session_expired';
|
|
4
|
+
export type AuditEvent = { id: number; at: number; kind: AuditKind; source: string | null; deviceName: string | null };
|
|
5
|
+
type Subject = { source?: string; deviceName?: string };
|
|
6
|
+
|
|
7
|
+
const RECENT_LIMIT = 50;
|
|
8
|
+
|
|
9
|
+
export class AuditLog {
|
|
10
|
+
constructor(private readonly database: MetadataDatabase, private readonly now = Date.now) {}
|
|
11
|
+
record(kind: AuditKind, { source, deviceName }: Subject = {}) {
|
|
12
|
+
this.database.addAuditEvent({ at: this.now(), kind, source: source ?? null, deviceName: deviceName ?? null });
|
|
13
|
+
}
|
|
14
|
+
recent(limit = RECENT_LIMIT) { return this.database.auditEvents(limit); }
|
|
15
|
+
}
|