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:
2026-06-11 22:04:09 +02:00
parent 903d5deeb5
commit 4768b606e4
25 changed files with 4467 additions and 474 deletions

3183
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View 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"
}
}

View 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"
}
}

View 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 };
}

View 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;
}
}

View 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,
};
}

View 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 };
}

View 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,
};
}
}

View 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;
}
}

View 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);
}

View 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);
});

View 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 });
});
}

View 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 });
});
}

View 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;
});
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"],
"references": [{ "path": "../shared" }]
}

File diff suppressed because one or more lines are too long

View 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"
}
}

View 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;
}

View File

@@ -0,0 +1,2 @@
export * from './protocol.js';
export * from './api.js';

View 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;
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long

459
spikes/package-lock.json generated
View File

@@ -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"
}
}
}

View File

@@ -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
View 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
}
}