P1 spine: monorepo, shared WS protocol, server daemon
- npm workspaces (shared / server / web), TS strict, project refs - @arboretum/shared: multiplexed WS protocol (JSON control + binary output frames: 1B type + u32le channel), flow-control constants (ACK 256K, HIGH 384K, LOW 128K, lagging 2M), REST types - git-arboretum server: Fastify 5 + node:sqlite (single native dep: node-pty prebuilt), token auth (sha256 at rest, HMAC cookie, global login rate limit + backoff), strict Origin check on /api and /ws, PtyManager (2MiB ring with monotonic offset, resync replay = reset + 256KiB tail, pause/resume only when ALL interactive clients exceed HIGH, observers never throttle, lagging clients resync), WS gateway (attach/stdin/resize/ack, heartbeat 30s), SIGTERM→SIGKILL 5s grace - CLI: arboretum [--port 7317] [--bind 127.0.0.1] — non-loopback bind requires an explicit safety flag - Smoke-tested: login/401/403-origin/spawn bash/kill/grace-SIGKILL all green
This commit is contained in:
3183
package-lock.json
generated
Normal file
3183
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "arboretum-monorepo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.16"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build -w @arboretum/shared -w git-arboretum -w @arboretum/web",
|
||||
"typecheck": "tsc -b packages/shared packages/server",
|
||||
"test": "vitest run",
|
||||
"dev:server": "npm run dev -w git-arboretum",
|
||||
"dev:web": "npm run dev -w @arboretum/web"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
34
packages/server/package.json
Normal file
34
packages/server/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "git-arboretum",
|
||||
"version": "0.1.0",
|
||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"arboretum": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/app.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"public"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.16"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"dev": "tsc -b --watch & node --watch dist/index.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arboretum/shared": "0.1.0",
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
||||
"fastify": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.0"
|
||||
}
|
||||
}
|
||||
91
packages/server/src/app.ts
Normal file
91
packages/server/src/app.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Config } from './config.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
||||
import { PtyManager } from './core/pty-manager.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerWsGateway } from './ws/gateway.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
authContext: AuthContext | null;
|
||||
}
|
||||
interface FastifyContextConfig {
|
||||
public?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppBundle {
|
||||
app: FastifyInstance;
|
||||
auth: AuthService;
|
||||
manager: PtyManager;
|
||||
}
|
||||
|
||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
||||
const auth = new AuthService(db);
|
||||
const limiter = new LoginRateLimiter();
|
||||
const manager = new PtyManager(db);
|
||||
|
||||
void app.register(fastifyCookie);
|
||||
void app.register(fastifyWebsocket, {
|
||||
options: { maxPayload: 1024 * 1024 },
|
||||
});
|
||||
|
||||
const allowedOrigins = new Set<string>([
|
||||
`http://127.0.0.1:${config.port}`,
|
||||
`http://localhost:${config.port}`,
|
||||
...config.allowedOrigins,
|
||||
]);
|
||||
|
||||
const authenticate = (req: FastifyRequest): AuthContext | null => {
|
||||
const bearer = req.headers.authorization;
|
||||
if (bearer?.startsWith('Bearer ')) {
|
||||
const ctx = auth.verifyRawToken(bearer.slice(7));
|
||||
if (ctx) return ctx;
|
||||
}
|
||||
return auth.verifyCookie(req.cookies[auth.cookieName]);
|
||||
};
|
||||
|
||||
// Garde globale : auth sur tout /api/** et /ws ; check Origin strict quand l'en-tête est présent
|
||||
// (anti cross-site WebSocket hijacking — le cookie SameSite=Strict ne suffit pas pour les upgrades).
|
||||
app.addHook('preValidation', async (req, reply) => {
|
||||
const isApi = req.url.startsWith('/api/');
|
||||
const isWs = req.url.startsWith('/ws');
|
||||
if (!isApi && !isWs) return; // statique : public (la SPA gère son écran de login)
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !allowedOrigins.has(origin)) {
|
||||
return reply.status(403).send({ error: { code: 'BAD_ORIGIN', message: `Origin not allowed: ${origin}` } });
|
||||
}
|
||||
req.authContext = authenticate(req);
|
||||
if (req.routeOptions.config.public) return;
|
||||
if (!req.authContext) {
|
||||
return reply.status(401).send({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } });
|
||||
}
|
||||
});
|
||||
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||
registerSessionRoutes(app, manager);
|
||||
registerWsGateway(app, manager, serverVersion);
|
||||
|
||||
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
||||
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
if (existsSync(publicDir)) {
|
||||
void app.register(fastifyStatic, { root: publicDir, wildcard: false });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
|
||||
return { app, auth, manager };
|
||||
}
|
||||
124
packages/server/src/auth/service.ts
Normal file
124
packages/server/src/auth/service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||
|
||||
const COOKIE_NAME = 'arb_session';
|
||||
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
|
||||
|
||||
export interface AuthContext {
|
||||
tokenId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private readonly secret: Buffer;
|
||||
|
||||
constructor(private readonly db: Db) {
|
||||
let secretHex = getSetting(db, 'server_secret');
|
||||
if (!secretHex) {
|
||||
secretHex = randomBytes(32).toString('hex');
|
||||
setSetting(db, 'server_secret', secretHex);
|
||||
}
|
||||
this.secret = Buffer.from(secretHex, 'hex');
|
||||
}
|
||||
|
||||
get cookieName(): string {
|
||||
return COOKIE_NAME;
|
||||
}
|
||||
|
||||
/** Crée le token initial au premier démarrage et renvoie sa valeur en clair (affichée une seule fois). */
|
||||
ensureBootstrapToken(): string | null {
|
||||
const row = this.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL')
|
||||
.get() as { n: number };
|
||||
if (row.n > 0) return null;
|
||||
return this.createToken('initial');
|
||||
}
|
||||
|
||||
createToken(label: string): string {
|
||||
const raw = `arb_${randomBytes(24).toString('hex')}`;
|
||||
this.db
|
||||
.prepare('INSERT INTO auth_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(randomUUID(), label, sha256(raw), new Date().toISOString());
|
||||
return raw;
|
||||
}
|
||||
|
||||
verifyRawToken(raw: string): AuthContext | null {
|
||||
if (typeof raw !== 'string' || raw.length < 8 || raw.length > 128) return null;
|
||||
const row = this.db
|
||||
.prepare('SELECT id, label, token_hash FROM auth_tokens WHERE revoked_at IS NULL AND token_hash = ?')
|
||||
.get(sha256(raw)) as { id: string; label: string; token_hash: string } | undefined;
|
||||
if (!row) return null;
|
||||
// double vérification en temps constant (le lookup par hash est déjà non-oracle, ceinture+bretelles)
|
||||
if (!timingSafeEqual(Buffer.from(row.token_hash), Buffer.from(sha256(raw)))) return null;
|
||||
this.db.prepare('UPDATE auth_tokens SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), row.id);
|
||||
return { tokenId: row.id, label: row.label };
|
||||
}
|
||||
|
||||
issueCookie(ctx: AuthContext): string {
|
||||
const expires = Date.now() + COOKIE_TTL_MS;
|
||||
const payload = `${ctx.tokenId}.${expires}`;
|
||||
return `${payload}.${this.sign(payload)}`;
|
||||
}
|
||||
|
||||
verifyCookie(value: string | undefined): AuthContext | null {
|
||||
if (!value) return null;
|
||||
const lastDot = value.lastIndexOf('.');
|
||||
if (lastDot <= 0) return null;
|
||||
const payload = value.slice(0, lastDot);
|
||||
const sig = value.slice(lastDot + 1);
|
||||
const expected = this.sign(payload);
|
||||
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
const [tokenId, expiresStr] = payload.split('.');
|
||||
if (!tokenId || !expiresStr || Number(expiresStr) < Date.now()) return null;
|
||||
const row = this.db
|
||||
.prepare('SELECT id, label FROM auth_tokens WHERE id = ? AND revoked_at IS NULL')
|
||||
.get(tokenId) as { id: string; label: string } | undefined;
|
||||
return row ? { tokenId: row.id, label: row.label } : null;
|
||||
}
|
||||
|
||||
private sign(payload: string): string {
|
||||
return createHmac('sha256', this.secret).update(payload).digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash('sha256').update(s).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit GLOBAL du login (pas par IP : derrière Tailscale Serve toutes les
|
||||
* requêtes arrivent de 127.0.0.1) + backoff exponentiel sur échecs consécutifs.
|
||||
*/
|
||||
export class LoginRateLimiter {
|
||||
private attempts: number[] = [];
|
||||
private consecutiveFailures = 0;
|
||||
private lockedUntil = 0;
|
||||
|
||||
constructor(
|
||||
private readonly maxPerMinute = 5,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
/** null = autorisé ; sinon délai d'attente en ms */
|
||||
check(): number | null {
|
||||
const t = this.now();
|
||||
if (t < this.lockedUntil) return this.lockedUntil - t;
|
||||
this.attempts = this.attempts.filter((a) => t - a < 60_000);
|
||||
if (this.attempts.length >= this.maxPerMinute) return 60_000 - (t - (this.attempts[0] ?? t));
|
||||
this.attempts.push(t);
|
||||
return null;
|
||||
}
|
||||
|
||||
recordFailure(): void {
|
||||
this.consecutiveFailures += 1;
|
||||
if (this.consecutiveFailures >= 3) {
|
||||
const backoff = Math.min(2 ** (this.consecutiveFailures - 3) * 30_000, 30 * 60_000);
|
||||
this.lockedUntil = this.now() + backoff;
|
||||
}
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.consecutiveFailures = 0;
|
||||
this.lockedUntil = 0;
|
||||
}
|
||||
}
|
||||
50
packages/server/src/config.ts
Normal file
50
packages/server/src/config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { parseArgs } from 'node:util';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
bind: string;
|
||||
dbPath: string;
|
||||
dataDir: string;
|
||||
/** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */
|
||||
allowedOrigins: string[];
|
||||
printToken: boolean;
|
||||
}
|
||||
|
||||
export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
port: { type: 'string', default: '7317' },
|
||||
bind: { type: 'string', default: '127.0.0.1' },
|
||||
db: { type: 'string' },
|
||||
'allow-origin': { type: 'string', multiple: true },
|
||||
'print-token': { type: 'boolean', default: false },
|
||||
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const bind = values.bind ?? '127.0.0.1';
|
||||
const loopback = bind === '127.0.0.1' || bind === '::1' || bind === 'localhost';
|
||||
if (!loopback && !values['i-know-this-exposes-a-terminal']) {
|
||||
throw new Error(
|
||||
`Refusing to bind to ${bind}: an Arboretum server is remote code execution by design.\n` +
|
||||
`Use Tailscale Serve against the default 127.0.0.1 bind (recommended), or pass\n` +
|
||||
`--i-know-this-exposes-a-terminal if you really know what you are doing.`,
|
||||
);
|
||||
}
|
||||
|
||||
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
return {
|
||||
port: Number(values.port),
|
||||
bind,
|
||||
dbPath: values.db ?? join(dataDir, 'arboretum.db'),
|
||||
dataDir,
|
||||
allowedOrigins: values['allow-origin'] ?? [],
|
||||
printToken: values['print-token'] ?? false,
|
||||
};
|
||||
}
|
||||
34
packages/server/src/core/claude-launcher.ts
Normal file
34
packages/server/src/core/claude-launcher.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
export interface SpawnSpec {
|
||||
file: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
let cachedClaudeBin: string | null = null;
|
||||
|
||||
export function resolveClaudeBin(): string {
|
||||
if (cachedClaudeBin) return cachedClaudeBin;
|
||||
try {
|
||||
cachedClaudeBin = execFileSync('which', ['claude'], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart',
|
||||
);
|
||||
}
|
||||
return cachedClaudeBin;
|
||||
}
|
||||
|
||||
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
||||
export function buildSpawnSpec(command: 'claude' | 'bash'): SpawnSpec {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
};
|
||||
if (command === 'bash') {
|
||||
return { file: 'bash', args: ['--norc'], env };
|
||||
}
|
||||
return { file: resolveClaudeBin(), args: [], env };
|
||||
}
|
||||
271
packages/server/src/core/pty-manager.ts
Normal file
271
packages/server/src/core/pty-manager.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
||||
import { RingBuffer } from './ring-buffer.js';
|
||||
import { buildSpawnSpec } from './claude-launcher.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
const RING_CAPACITY = 2 * 1024 * 1024;
|
||||
const KILL_GRACE_MS = 5000;
|
||||
|
||||
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
|
||||
export interface ClientBinding {
|
||||
channel: number;
|
||||
mode: 'interactive' | 'observer';
|
||||
controlling: boolean;
|
||||
sentBytes: number;
|
||||
ackedBytes: number;
|
||||
lagging: boolean;
|
||||
sendOutput: (payload: Buffer) => void;
|
||||
sendResync: (payload: Buffer) => void;
|
||||
onDetached: (reason: 'session_exit' | 'replaced') => void;
|
||||
onControlChanged: (controlling: boolean) => void;
|
||||
}
|
||||
|
||||
interface ManagedSession {
|
||||
id: string;
|
||||
cwd: string;
|
||||
command: 'claude' | 'bash';
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
proc: pty.IPty;
|
||||
ring: RingBuffer;
|
||||
clients: Set<ClientBinding>;
|
||||
paused: boolean;
|
||||
exited: { exitCode: number | null; signal: number | null } | null;
|
||||
killTimer: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
export interface PtyManagerEvents {
|
||||
session_update: [SessionSummary];
|
||||
session_exit: [{ sessionId: string; exitCode: number | null; signal: number | null }];
|
||||
}
|
||||
|
||||
export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
private readonly live = new Map<string, ManagedSession>();
|
||||
|
||||
constructor(private readonly db: Db) {
|
||||
super();
|
||||
}
|
||||
|
||||
spawn(opts: { cwd: string; command?: 'claude' | 'bash' }): SessionSummary {
|
||||
const cwd = opts.cwd;
|
||||
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
|
||||
}
|
||||
const command = opts.command ?? 'claude';
|
||||
const spec = buildSpawnSpec(command);
|
||||
const id = randomUUID();
|
||||
const proc = pty.spawn(spec.file, spec.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
cwd,
|
||||
env: spec.env as { [key: string]: string },
|
||||
});
|
||||
const session: ManagedSession = {
|
||||
id,
|
||||
cwd,
|
||||
command,
|
||||
title: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
proc,
|
||||
ring: new RingBuffer(RING_CAPACITY),
|
||||
clients: new Set(),
|
||||
paused: false,
|
||||
exited: null,
|
||||
killTimer: null,
|
||||
};
|
||||
this.live.set(id, session);
|
||||
this.db
|
||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(id, cwd, command, session.createdAt);
|
||||
|
||||
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
||||
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
||||
|
||||
const summary = this.summarize(session);
|
||||
this.emit('session_update', summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
list(): SessionSummary[] {
|
||||
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
||||
const liveIds = new Set(this.live.keys());
|
||||
const rows = this.db
|
||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code FROM sessions ORDER BY created_at DESC LIMIT 100')
|
||||
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null }>;
|
||||
const historical: SessionSummary[] = rows
|
||||
.filter((r) => !liveIds.has(r.id))
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
cwd: r.cwd,
|
||||
command: r.command,
|
||||
title: r.title,
|
||||
status: 'exited',
|
||||
live: false,
|
||||
createdAt: r.created_at,
|
||||
endedAt: r.ended_at,
|
||||
exitCode: r.exit_code,
|
||||
clients: 0,
|
||||
}));
|
||||
return [...liveSummaries, ...historical];
|
||||
}
|
||||
|
||||
get(id: string): SessionSummary | null {
|
||||
const s = this.live.get(id);
|
||||
return s ? this.summarize(s) : null;
|
||||
}
|
||||
|
||||
kill(id: string): boolean {
|
||||
const s = this.live.get(id);
|
||||
if (!s || s.exited) return false;
|
||||
try {
|
||||
process.kill(s.proc.pid, 'SIGTERM');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
s.killTimer ??= setTimeout(() => {
|
||||
if (!s.exited) {
|
||||
try {
|
||||
process.kill(s.proc.pid, 'SIGKILL');
|
||||
} catch {
|
||||
/* déjà mort */
|
||||
}
|
||||
}
|
||||
}, KILL_GRACE_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM — spike S1). */
|
||||
shutdown(): void {
|
||||
for (const id of this.live.keys()) this.kill(id);
|
||||
}
|
||||
|
||||
// ---- attach / detach / io ----
|
||||
|
||||
attach(sessionId: string, binding: ClientBinding, cols: number, rows: number): { ok: true; controlling: boolean } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s) return { ok: false, code: 'NOT_FOUND' };
|
||||
if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
|
||||
const hasController = [...s.clients].some((c) => c.controlling);
|
||||
binding.controlling = binding.mode === 'interactive' && !hasController;
|
||||
s.clients.add(binding);
|
||||
if (binding.controlling) s.proc.resize(cols, rows);
|
||||
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
|
||||
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
||||
binding.sentBytes = 0;
|
||||
binding.ackedBytes = 0;
|
||||
return { ok: true, controlling: binding.controlling };
|
||||
}
|
||||
|
||||
detach(sessionId: string, binding: ClientBinding): void {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s) return;
|
||||
s.clients.delete(binding);
|
||||
if (binding.controlling) {
|
||||
const next = [...s.clients].find((c) => c.mode === 'interactive');
|
||||
if (next) {
|
||||
next.controlling = true;
|
||||
next.onControlChanged(true);
|
||||
}
|
||||
}
|
||||
this.updateFlowControl(s);
|
||||
}
|
||||
|
||||
write(sessionId: string, binding: ClientBinding, data: string): 'ok' | 'not_controlling' | 'gone' {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s || s.exited) return 'gone';
|
||||
// Outil mono-utilisateur : tous les interactifs peuvent écrire ; les observers jamais.
|
||||
if (binding.mode !== 'interactive') return 'not_controlling';
|
||||
s.proc.write(data);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s || s.exited || !binding.controlling) return;
|
||||
s.proc.resize(cols, rows);
|
||||
}
|
||||
|
||||
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s) return;
|
||||
binding.ackedBytes = Math.max(binding.ackedBytes, bytes);
|
||||
if (binding.lagging && binding.sentBytes - binding.ackedBytes < FLOW.LOW_WATERMARK) {
|
||||
binding.lagging = false;
|
||||
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
||||
binding.sentBytes = 0;
|
||||
binding.ackedBytes = 0;
|
||||
}
|
||||
this.updateFlowControl(s);
|
||||
}
|
||||
|
||||
// ---- interne ----
|
||||
|
||||
private handleOutput(s: ManagedSession, chunk: Buffer): void {
|
||||
s.ring.write(chunk);
|
||||
for (const c of s.clients) {
|
||||
if (c.lagging) continue;
|
||||
c.sendOutput(chunk);
|
||||
c.sentBytes += chunk.length;
|
||||
if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
|
||||
}
|
||||
this.updateFlowControl(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
|
||||
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY.
|
||||
*/
|
||||
private updateFlowControl(s: ManagedSession): void {
|
||||
if (s.exited) return;
|
||||
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && !c.lagging);
|
||||
if (interactive.length === 0) {
|
||||
if (s.paused) {
|
||||
s.proc.resume();
|
||||
s.paused = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const outstandings = interactive.map((c) => c.sentBytes - c.ackedBytes);
|
||||
const min = Math.min(...outstandings);
|
||||
if (!s.paused && outstandings.every((o) => o > FLOW.HIGH_WATERMARK)) {
|
||||
s.proc.pause();
|
||||
s.paused = true;
|
||||
} else if (s.paused && min < FLOW.LOW_WATERMARK) {
|
||||
s.proc.resume();
|
||||
s.paused = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
|
||||
s.exited = { exitCode, signal };
|
||||
if (s.killTimer) clearTimeout(s.killTimer);
|
||||
this.db
|
||||
.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?')
|
||||
.run(new Date().toISOString(), exitCode, s.id);
|
||||
for (const c of s.clients) c.onDetached('session_exit');
|
||||
s.clients.clear();
|
||||
this.live.delete(s.id);
|
||||
this.emit('session_exit', { sessionId: s.id, exitCode, signal });
|
||||
this.emit('session_update', { ...this.summarize(s), live: false, status: 'exited' });
|
||||
}
|
||||
|
||||
private summarize(s: ManagedSession): SessionSummary {
|
||||
return {
|
||||
id: s.id,
|
||||
cwd: s.cwd,
|
||||
command: s.command,
|
||||
title: s.title,
|
||||
status: s.exited ? 'exited' : 'running',
|
||||
live: !s.exited,
|
||||
createdAt: s.createdAt,
|
||||
endedAt: null,
|
||||
exitCode: s.exited?.exitCode ?? null,
|
||||
clients: s.clients.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
67
packages/server/src/core/ring-buffer.ts
Normal file
67
packages/server/src/core/ring-buffer.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Ring buffer binaire à offset monotone.
|
||||
* `totalOffset` compte tous les octets jamais écrits ; le ring ne garde que la
|
||||
* fenêtre récente. Un lecteur qui demande un offset sorti de la fenêtre reçoit
|
||||
* null et doit resync (reset terminal + queue du ring).
|
||||
*/
|
||||
export class RingBuffer {
|
||||
private buf: Buffer;
|
||||
private length = 0; // octets valides dans la fenêtre
|
||||
private total = 0; // offset monotone (Number : sûr jusqu'à 2^53)
|
||||
|
||||
constructor(readonly capacity: number) {
|
||||
this.buf = Buffer.alloc(capacity);
|
||||
}
|
||||
|
||||
get totalOffset(): number {
|
||||
return this.total;
|
||||
}
|
||||
|
||||
get windowStart(): number {
|
||||
return this.total - this.length;
|
||||
}
|
||||
|
||||
write(chunk: Buffer): void {
|
||||
if (chunk.length >= this.capacity) {
|
||||
chunk.copy(this.buf, 0, chunk.length - this.capacity);
|
||||
this.length = this.capacity;
|
||||
} else {
|
||||
const writePos = this.total % this.capacity;
|
||||
const tailSpace = this.capacity - writePos;
|
||||
if (chunk.length <= tailSpace) {
|
||||
chunk.copy(this.buf, writePos);
|
||||
} else {
|
||||
chunk.copy(this.buf, writePos, 0, tailSpace);
|
||||
chunk.copy(this.buf, 0, tailSpace);
|
||||
}
|
||||
this.length = Math.min(this.capacity, this.length + chunk.length);
|
||||
}
|
||||
this.total += chunk.length;
|
||||
}
|
||||
|
||||
/** Octets depuis `offset` (inclus), ou null si l'offset est sorti de la fenêtre. */
|
||||
readFrom(offset: number): Buffer | null {
|
||||
if (offset > this.total) return null;
|
||||
if (offset === this.total) return Buffer.alloc(0);
|
||||
if (offset < this.windowStart) return null;
|
||||
const count = this.total - offset;
|
||||
return this.tail(count);
|
||||
}
|
||||
|
||||
/** Les `count` derniers octets (bornés à la fenêtre), dans l'ordre. */
|
||||
tail(count: number): Buffer {
|
||||
const n = Math.min(count, this.length);
|
||||
const out = Buffer.alloc(n);
|
||||
const end = this.total % this.capacity;
|
||||
const start = (end - n + this.capacity * 2) % this.capacity;
|
||||
if (n === 0) return out;
|
||||
if (start < end || end === 0) {
|
||||
this.buf.copy(out, 0, start, start + n);
|
||||
} else {
|
||||
const firstPart = this.capacity - start;
|
||||
this.buf.copy(out, 0, start, this.capacity);
|
||||
this.buf.copy(out, firstPart, 0, end);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
68
packages/server/src/db/index.ts
Normal file
68
packages/server/src/db/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||
{
|
||||
id: 1,
|
||||
sql: `
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE auth_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
cwd TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
title TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
exit_code INTEGER
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export type Db = DatabaseSync;
|
||||
|
||||
export function openDb(path: string): Db {
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function migrate(db: DatabaseSync): void {
|
||||
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
|
||||
const applied = new Set(
|
||||
(db.prepare('SELECT id FROM schema_migrations').all() as Array<{ id: number }>).map((r) => r.id),
|
||||
);
|
||||
for (const m of MIGRATIONS) {
|
||||
if (applied.has(m.id)) continue;
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.exec(m.sql);
|
||||
db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run(m.id, new Date().toISOString());
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getSetting(db: Db, key: string): string | null {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setSetting(db: Db, key: string, value: string): void {
|
||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
||||
}
|
||||
51
packages/server/src/index.ts
Normal file
51
packages/server/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig } from './config.js';
|
||||
import { openDb } from './db/index.js';
|
||||
import { buildApp } from './app.js';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const db = openDb(config.dbPath);
|
||||
const { app, auth, manager } = buildApp(config, db, pkg.version);
|
||||
|
||||
const bootstrapToken = auth.ensureBootstrapToken();
|
||||
await app.listen({ port: config.port, host: config.bind });
|
||||
|
||||
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
|
||||
app.log.info(`Arboretum v${pkg.version} — ${url}`);
|
||||
if (bootstrapToken) {
|
||||
// Affiché une seule fois : le hash seul est stocké.
|
||||
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
|
||||
console.log('│ First start — your access token (shown once, store it safely): │');
|
||||
console.log('└──────────────────────────────────────────────────────────────────┘');
|
||||
console.log(`\n ${bootstrapToken}\n`);
|
||||
console.log(` Login at: ${url}/\n`);
|
||||
} else if (config.printToken) {
|
||||
console.log('Tokens are stored hashed and cannot be re-printed. Create a new one from Settings (or reset the db).');
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = (signal: string): void => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
app.log.info(`${signal} received — draining sessions then exiting`);
|
||||
manager.shutdown();
|
||||
setTimeout(() => {
|
||||
void app.close().then(() => process.exit(0));
|
||||
}, 1000).unref();
|
||||
};
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
42
packages/server/src/routes/auth.ts
Normal file
42
packages/server/src/routes/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
|
||||
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
||||
|
||||
export function registerAuthRoutes(
|
||||
app: FastifyInstance,
|
||||
auth: AuthService,
|
||||
limiter: LoginRateLimiter,
|
||||
serverVersion: string,
|
||||
): void {
|
||||
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
|
||||
const wait = limiter.check();
|
||||
if (wait !== null) {
|
||||
return reply.status(429).send({ error: { code: 'RATE_LIMITED', message: `Retry in ${Math.ceil(wait / 1000)}s` } });
|
||||
}
|
||||
const body = req.body as Partial<LoginRequest> | null;
|
||||
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
|
||||
if (!ctx) {
|
||||
limiter.recordFailure();
|
||||
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
|
||||
}
|
||||
limiter.recordSuccess();
|
||||
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 30 * 24 * 3600,
|
||||
});
|
||||
const res: LoginResponse = { ok: true, label: ctx.label };
|
||||
return reply.send(res);
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
const res: MeResponse = { ok: true, tokenLabel: req.authContext?.label ?? 'unknown', serverVersion };
|
||||
return reply.send(res);
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/logout', async (_req, reply) => {
|
||||
void reply.clearCookie(auth.cookieName, { path: '/' });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
35
packages/server/src/routes/sessions.ts
Normal file
35
packages/server/src/routes/sessions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { CreateSessionRequest, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
||||
import type { PtyManager } from '../core/pty-manager.js';
|
||||
|
||||
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager): void {
|
||||
app.get('/api/v1/sessions', async (): Promise<SessionsListResponse> => {
|
||||
return { sessions: manager.list() };
|
||||
});
|
||||
|
||||
app.post('/api/v1/sessions', async (req, reply) => {
|
||||
const body = req.body as Partial<CreateSessionRequest> | null;
|
||||
if (!body || typeof body.cwd !== 'string' || !body.cwd.startsWith('/')) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'cwd (absolute path) is required' } });
|
||||
}
|
||||
if (body.command !== undefined && body.command !== 'claude' && body.command !== 'bash') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
|
||||
}
|
||||
try {
|
||||
const session = manager.spawn({ cwd: body.cwd, ...(body.command ? { command: body.command } : {}) });
|
||||
const res: SessionResponse = { session };
|
||||
return reply.status(201).send(res);
|
||||
} catch (err) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/v1/sessions/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!manager.kill(id)) {
|
||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No live session with this id' } });
|
||||
}
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
157
packages/server/src/ws/gateway.ts
Normal file
157
packages/server/src/ws/gateway.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import {
|
||||
BINARY_FRAME,
|
||||
PROTOCOL_VERSION,
|
||||
encodeBinaryFrame,
|
||||
parseClientMessage,
|
||||
type ServerMessage,
|
||||
type SessionSummary,
|
||||
} from '@arboretum/shared';
|
||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
|
||||
interface ChannelState {
|
||||
sessionId: string;
|
||||
binding: ClientBinding;
|
||||
}
|
||||
|
||||
export function registerWsGateway(app: FastifyInstance, manager: PtyManager, serverVersion: string): void {
|
||||
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
||||
// L'auth + le check Origin ont eu lieu dans le preValidation global (app.ts).
|
||||
const channels = new Map<number, ChannelState>();
|
||||
let nextChannel = 1;
|
||||
let helloDone = false;
|
||||
let subscribedSessions = false;
|
||||
let alive = true;
|
||||
|
||||
const send = (msg: ServerMessage): void => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
|
||||
};
|
||||
const sendBinary = (type: number, channel: number, payload: Buffer): void => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(encodeBinaryFrame(type, channel, payload));
|
||||
};
|
||||
|
||||
const onSessionUpdate = (session: SessionSummary): void => {
|
||||
if (subscribedSessions) send({ type: 'session_update', session });
|
||||
};
|
||||
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
|
||||
if (subscribedSessions) send({ type: 'session_exit', ...e });
|
||||
};
|
||||
manager.on('session_update', onSessionUpdate);
|
||||
manager.on('session_exit', onSessionExit);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
if (!alive) {
|
||||
socket.terminate();
|
||||
return;
|
||||
}
|
||||
alive = false;
|
||||
socket.ping();
|
||||
}, HEARTBEAT_MS);
|
||||
socket.on('pong', () => {
|
||||
alive = true;
|
||||
});
|
||||
|
||||
socket.on('message', (raw, isBinary) => {
|
||||
if (isBinary) return; // P1 : aucune frame binaire attendue côté client
|
||||
const msg = parseClientMessage(String(raw));
|
||||
if (!msg) {
|
||||
send({ type: 'error', code: 'BAD_MESSAGE', message: 'Unparseable or invalid message' });
|
||||
return;
|
||||
}
|
||||
if (!helloDone && msg.type !== 'hello') {
|
||||
send({ type: 'error', code: 'BAD_PROTOCOL', message: 'Expected hello first' });
|
||||
return;
|
||||
}
|
||||
switch (msg.type) {
|
||||
case 'hello': {
|
||||
if (msg.protocol !== PROTOCOL_VERSION) {
|
||||
send({ type: 'error', code: 'BAD_PROTOCOL', message: `Server speaks protocol ${PROTOCOL_VERSION}` });
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
helloDone = true;
|
||||
send({ type: 'hello_ok', protocol: PROTOCOL_VERSION, serverVersion });
|
||||
return;
|
||||
}
|
||||
case 'sub': {
|
||||
subscribedSessions = msg.topics.includes('sessions');
|
||||
return;
|
||||
}
|
||||
case 'attach': {
|
||||
const channel = nextChannel++;
|
||||
const binding: ClientBinding = {
|
||||
channel,
|
||||
mode: msg.mode,
|
||||
controlling: false,
|
||||
sentBytes: 0,
|
||||
ackedBytes: 0,
|
||||
lagging: false,
|
||||
sendOutput: (payload) => sendBinary(BINARY_FRAME.OUTPUT, channel, payload),
|
||||
sendResync: (payload) => sendBinary(BINARY_FRAME.RESYNC, channel, payload),
|
||||
onDetached: (reason) => {
|
||||
channels.delete(channel);
|
||||
send({ type: 'detached', channel, reason });
|
||||
},
|
||||
onControlChanged: (controlling) => send({ type: 'control_changed', channel, controlling }),
|
||||
};
|
||||
const res = manager.attach(msg.sessionId, binding, msg.cols, msg.rows);
|
||||
if (!res.ok) {
|
||||
send({ type: 'error', code: res.code, message: `Cannot attach: ${res.code}` });
|
||||
return;
|
||||
}
|
||||
channels.set(channel, { sessionId: msg.sessionId, binding });
|
||||
send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling });
|
||||
return;
|
||||
}
|
||||
case 'detach': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (!st) return;
|
||||
channels.delete(msg.channel);
|
||||
manager.detach(st.sessionId, st.binding);
|
||||
send({ type: 'detached', channel: msg.channel, reason: 'client' });
|
||||
return;
|
||||
}
|
||||
case 'stdin': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (!st) {
|
||||
send({ type: 'error', code: 'NOT_ATTACHED', message: 'Unknown channel', channel: msg.channel });
|
||||
return;
|
||||
}
|
||||
const r = manager.write(st.sessionId, st.binding, msg.data);
|
||||
if (r === 'not_controlling') {
|
||||
send({ type: 'error', code: 'NOT_CONTROLLING', message: 'Observer mode is read-only', channel: msg.channel });
|
||||
} else if (r === 'gone') {
|
||||
send({ type: 'error', code: 'SESSION_EXITED', message: 'Session has exited', channel: msg.channel });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'resize': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);
|
||||
return;
|
||||
}
|
||||
case 'ack': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (st) manager.ack(st.sessionId, st.binding, msg.bytes);
|
||||
return;
|
||||
}
|
||||
case 'ping':
|
||||
send({ type: 'pong' });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
manager.off('session_update', onSessionUpdate);
|
||||
manager.off('session_exit', onSessionExit);
|
||||
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||
channels.clear();
|
||||
});
|
||||
|
||||
void req;
|
||||
});
|
||||
}
|
||||
10
packages/server/tsconfig.json
Normal file
10
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../shared" }]
|
||||
}
|
||||
1
packages/server/tsconfig.tsbuildinfo
Normal file
1
packages/server/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
20
packages/shared/package.json
Normal file
20
packages/shared/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@arboretum/shared",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"dev": "tsc -b --watch"
|
||||
}
|
||||
}
|
||||
31
packages/shared/src/api.ts
Normal file
31
packages/shared/src/api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Types REST partagés (préfixe /api/v1) — sous-ensemble P1.
|
||||
import type { SessionSummary } from './protocol.js';
|
||||
|
||||
export interface ApiError {
|
||||
error: { code: string; message: string; details?: unknown };
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
token: string;
|
||||
}
|
||||
export interface LoginResponse {
|
||||
ok: true;
|
||||
label: string;
|
||||
}
|
||||
export interface MeResponse {
|
||||
ok: true;
|
||||
tokenLabel: string;
|
||||
serverVersion: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
cwd: string;
|
||||
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests d'acceptation sans quota */
|
||||
command?: 'claude' | 'bash';
|
||||
}
|
||||
export interface SessionsListResponse {
|
||||
sessions: SessionSummary[];
|
||||
}
|
||||
export interface SessionResponse {
|
||||
session: SessionSummary;
|
||||
}
|
||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './protocol.js';
|
||||
export * from './api.js';
|
||||
142
packages/shared/src/protocol.ts
Normal file
142
packages/shared/src/protocol.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// Protocole WebSocket Arboretum — une connexion multiplexée par client.
|
||||
// Messages de contrôle : frames TEXTE JSON. Sortie terminal : frames BINAIRES
|
||||
// (un chunk PTY peut couper un caractère UTF-8 en frontière de frame ; le
|
||||
// décodage incombe à xterm.write(Uint8Array) côté client, jamais au transport).
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
// ---- Frames binaires ----
|
||||
// Layout : [type u8][channel u32le][payload...]
|
||||
export const BINARY_FRAME = {
|
||||
HEADER_BYTES: 5,
|
||||
/** serveur → client : sortie terminal */
|
||||
OUTPUT: 0x01,
|
||||
/** serveur → client : resync — le client doit reset son terminal avant d'écrire le payload */
|
||||
RESYNC: 0x02,
|
||||
} as const;
|
||||
|
||||
export function encodeBinaryFrame(type: number, channel: number, payload: Uint8Array): Uint8Array {
|
||||
const frame = new Uint8Array(BINARY_FRAME.HEADER_BYTES + payload.byteLength);
|
||||
const view = new DataView(frame.buffer);
|
||||
view.setUint8(0, type);
|
||||
view.setUint32(1, channel, true);
|
||||
frame.set(payload, BINARY_FRAME.HEADER_BYTES);
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function decodeBinaryFrame(data: Uint8Array): { type: number; channel: number; payload: Uint8Array } {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
return {
|
||||
type: view.getUint8(0),
|
||||
channel: view.getUint32(1, true),
|
||||
payload: data.subarray(BINARY_FRAME.HEADER_BYTES),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Flow control (pattern officiel xterm.js, watermarks du design) ----
|
||||
export const FLOW = {
|
||||
/** le client ACK tous les N octets réellement traités par xterm.write */
|
||||
ACK_EVERY_BYTES: 256 * 1024,
|
||||
/** pause du PTY quand TOUS les clients interactifs dépassent HIGH */
|
||||
HIGH_WATERMARK: 384 * 1024,
|
||||
/** reprise quand le min repasse sous LOW */
|
||||
LOW_WATERMARK: 128 * 1024,
|
||||
/** au-delà : client marqué lagging, flux coupé, resync au rattrapage */
|
||||
LAGGING_BYTES: 2 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
/** Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu) */
|
||||
export const REPLAY_TAIL_BYTES = 256 * 1024;
|
||||
|
||||
// ---- États de session (sous-ensemble P1 ; étendu en P3) ----
|
||||
export type SessionRuntimeStatus =
|
||||
| 'starting'
|
||||
| 'running' // P1 : pas encore de distinction busy/waiting/idle (P3 via claude-adapter)
|
||||
| 'exited';
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
cwd: string;
|
||||
command: string;
|
||||
title: string | null;
|
||||
status: SessionRuntimeStatus;
|
||||
live: boolean;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
exitCode: number | null;
|
||||
clients: number;
|
||||
}
|
||||
|
||||
// ---- Messages client → serveur ----
|
||||
export type ClientMessage =
|
||||
| { type: 'hello'; protocol: number }
|
||||
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
|
||||
| { type: 'detach'; channel: number }
|
||||
| { type: 'stdin'; channel: number; data: string }
|
||||
| { type: 'resize'; channel: number; cols: number; rows: number }
|
||||
| { type: 'ack'; channel: number; bytes: number }
|
||||
| { type: 'sub'; topics: Array<'sessions'> }
|
||||
| { type: 'ping' };
|
||||
|
||||
// ---- Messages serveur → client ----
|
||||
export type ServerMessage =
|
||||
| { type: 'hello_ok'; protocol: number; serverVersion: string }
|
||||
| { type: 'attached'; channel: number; sessionId: string; mode: 'interactive' | 'observer'; controlling: boolean }
|
||||
| { type: 'detached'; channel: number; reason: 'client' | 'session_exit' | 'replaced' }
|
||||
| { type: 'control_changed'; channel: number; controlling: boolean }
|
||||
| { type: 'session_update'; session: SessionSummary }
|
||||
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
|
||||
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
||||
| { type: 'pong' };
|
||||
|
||||
export type ErrorCode =
|
||||
| 'BAD_PROTOCOL'
|
||||
| 'BAD_MESSAGE'
|
||||
| 'NOT_FOUND'
|
||||
| 'NOT_ATTACHED'
|
||||
| 'NOT_CONTROLLING'
|
||||
| 'SESSION_EXITED'
|
||||
| 'INTERNAL';
|
||||
|
||||
export function parseClientMessage(raw: string): ClientMessage | null {
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof obj !== 'object' || obj === null || typeof (obj as { type?: unknown }).type !== 'string') return null;
|
||||
const m = obj as Record<string, unknown>;
|
||||
const isU32 = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 0xffffffff;
|
||||
const isDim = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 2 && v <= 1000;
|
||||
switch (m.type) {
|
||||
case 'hello':
|
||||
return typeof m.protocol === 'number' ? { type: 'hello', protocol: m.protocol } : null;
|
||||
case 'attach':
|
||||
return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows)
|
||||
? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows }
|
||||
: null;
|
||||
case 'detach':
|
||||
return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null;
|
||||
case 'stdin':
|
||||
return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536
|
||||
? { type: 'stdin', channel: m.channel, data: m.data }
|
||||
: null;
|
||||
case 'resize':
|
||||
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
|
||||
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
|
||||
: null;
|
||||
case 'ack':
|
||||
return isU32(m.channel) && typeof m.bytes === 'number' && m.bytes >= 0
|
||||
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
||||
: null;
|
||||
case 'sub':
|
||||
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions')
|
||||
? { type: 'sub', topics: m.topics as Array<'sessions'> }
|
||||
: null;
|
||||
case 'ping':
|
||||
return { type: 'ping' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
packages/shared/tsconfig.tsbuildinfo
Normal file
1
packages/shared/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
459
spikes/package-lock.json
generated
459
spikes/package-lock.json
generated
@@ -1,459 +0,0 @@
|
||||
{
|
||||
"name": "spikes",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "spikes",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@homebridge/node-pty-prebuilt-multiarch": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz",
|
||||
"integrity": "sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0",
|
||||
"prebuild-install": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.92.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
|
||||
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "spikes",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1"
|
||||
}
|
||||
}
|
||||
21
tsconfig.base.json
Normal file
21
tsconfig.base.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user