remove seed directory, clean up provisioning and sync modules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePassword } from './validate-password';
|
||||
import { validateUsername } from './validate-username';
|
||||
import { provisionLinuxUser } from '../users/provision';
|
||||
import { provisionUserEnvironment } from '../users/provision';
|
||||
|
||||
export const verifyHandler: Handler = async function (ctx) {
|
||||
const { verificationCode, name, username, password, confirmPassword } = ctx.get('body');
|
||||
@@ -43,9 +43,9 @@ export const verifyHandler: Handler = async function (ctx) {
|
||||
const finalUser = await getUserById(userInfo.id);
|
||||
if (!finalUser) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Provision Linux user for terminal/Pi/Claude Code isolation
|
||||
provisionLinuxUser(finalUser.email, finalUser.username ?? '').catch((err) => {
|
||||
console.error('[verify] failed to provision Linux user:', err);
|
||||
// Provision user environment (directories, configs, VNC)
|
||||
provisionUserEnvironment(finalUser.email, finalUser.username ?? '').catch((err) => {
|
||||
console.error('[verify] failed to provision user environment:', err);
|
||||
});
|
||||
|
||||
// Issue a token so the user is logged in immediately
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DATA_PATH, getHomeDir, toShellUsername } from '@@/data-path';
|
||||
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
|
||||
|
||||
const TEMPLATE_DIR = join(import.meta.dir, '../terminal/templates');
|
||||
const SHARED_GROUP = 'officerdev';
|
||||
|
||||
const run = (cmd: string[], opts?: { cwd?: string }): boolean => {
|
||||
const result = Bun.spawnSync({ cmd, stdout: 'ignore', stderr: 'pipe', ...opts });
|
||||
@@ -14,60 +13,23 @@ const run = (cmd: string[], opts?: { cwd?: string }): boolean => {
|
||||
return result.exitCode === 0;
|
||||
};
|
||||
|
||||
const linuxUserExists = (username: string): boolean => {
|
||||
const result = Bun.spawnSync({ cmd: ['id', username], stdout: 'ignore', stderr: 'ignore' });
|
||||
return result.exitCode === 0;
|
||||
};
|
||||
|
||||
const groupExists = (group: string): boolean => {
|
||||
const result = Bun.spawnSync({ cmd: ['getent', 'group', group], stdout: 'ignore', stderr: 'ignore' });
|
||||
return result.exitCode === 0;
|
||||
};
|
||||
|
||||
const copyTemplate = async (src: string, dest: string) => {
|
||||
if (existsSync(dest)) return;
|
||||
const content = await Bun.file(src).text();
|
||||
await Bun.write(dest, content);
|
||||
};
|
||||
|
||||
export function ensureSharedGroup(): void {
|
||||
if (groupExists(SHARED_GROUP)) return;
|
||||
run(['sudo', 'groupadd', SHARED_GROUP]);
|
||||
const serviceUser = process.env.USER ?? '';
|
||||
if (serviceUser) {
|
||||
run(['sudo', 'usermod', '-aG', SHARED_GROUP, serviceUser]);
|
||||
}
|
||||
console.log(`[provision] created shared group ${SHARED_GROUP} and added ${serviceUser}`);
|
||||
}
|
||||
|
||||
export async function provisionLinuxUser(email: string, username: string): Promise<boolean> {
|
||||
export async function provisionUserEnvironment(email: string, username: string): Promise<boolean> {
|
||||
const shellUsername = toShellUsername(username, email);
|
||||
const homeDir = getHomeDir(email);
|
||||
const userRoot = join(DATA_PATH, email);
|
||||
|
||||
console.log(`[provision] provisioning Linux user ${shellUsername} for ${email}`);
|
||||
|
||||
// Ensure shared group exists
|
||||
ensureSharedGroup();
|
||||
console.log(`[provision] provisioning environment for ${email}`);
|
||||
|
||||
// Ensure data directories exist
|
||||
mkdirSync(userRoot, { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
|
||||
// Create Linux user if not exists
|
||||
if (!linuxUserExists(shellUsername)) {
|
||||
const ok = run(['sudo', 'useradd', '-d', homeDir, '-s', '/bin/zsh', '-G', SHARED_GROUP, '-M', shellUsername]);
|
||||
if (!ok) {
|
||||
console.error(`[provision] failed to create Linux user ${shellUsername}`);
|
||||
return false;
|
||||
}
|
||||
console.log(`[provision] created Linux user ${shellUsername}`);
|
||||
} else {
|
||||
// Ensure existing user is in the shared group
|
||||
run(['sudo', 'usermod', '-aG', SHARED_GROUP, shellUsername]);
|
||||
console.log(`[provision] Linux user ${shellUsername} already exists`);
|
||||
}
|
||||
|
||||
// Seed shell config files
|
||||
await seedShellConfigs(homeDir);
|
||||
|
||||
@@ -86,13 +48,7 @@ export async function provisionLinuxUser(email: string, username: string): Promi
|
||||
// Provision VNC environment
|
||||
await provisionVncEnv(homeDir);
|
||||
|
||||
// Service user (pastilhas) owns everything — server can always read/write.
|
||||
// User's personal group gives only that user terminal access. Others get nothing.
|
||||
const serviceUser = process.env.USER ?? 'pastilhas';
|
||||
run(['sudo', 'chown', '-R', `${serviceUser}:${shellUsername}`, userRoot]);
|
||||
run(['sudo', 'chmod', '-R', '2770', userRoot]);
|
||||
|
||||
console.log(`[provision] provisioning complete for ${shellUsername}`);
|
||||
console.log(`[provision] provisioning complete for ${email}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -186,21 +142,9 @@ exec startxfce4
|
||||
console.log(`[provision] VNC environment provisioned at ${vncDir}`);
|
||||
}
|
||||
|
||||
export function deprovisionLinuxUser(email: string, username: string): boolean {
|
||||
const shellUsername = toShellUsername(username, email);
|
||||
console.log(`[provision] deprovisioning Linux user ${shellUsername}`);
|
||||
|
||||
if (!linuxUserExists(shellUsername)) {
|
||||
console.log(`[provision] Linux user ${shellUsername} does not exist, skipping`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const ok = run(['sudo', 'userdel', shellUsername]);
|
||||
if (!ok) {
|
||||
console.error(`[provision] failed to delete Linux user ${shellUsername}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[provision] deprovisioned Linux user ${shellUsername}`);
|
||||
export function deprovisionUserEnvironment(email: string, _username: string): boolean {
|
||||
// User data directories are intentionally kept on disk.
|
||||
// This function exists for API compatibility.
|
||||
console.log(`[provision] deprovision called for ${email} (no-op, data kept on disk)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { sendMail } from 'emailer';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { originMiddleware } from '@@/_middlewares';
|
||||
import { updateUserHandler } from './update-user';
|
||||
import { deprovisionLinuxUser } from './provision';
|
||||
import { deprovisionUserEnvironment } from './provision';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
@@ -103,8 +103,8 @@ usersRouter.delete('/:id', async (ctx) => {
|
||||
const target = await getUserById(id);
|
||||
if (!target) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Deprovision Linux user before deleting from database
|
||||
deprovisionLinuxUser(target.email, target.username ?? '');
|
||||
// Deprovision user environment before deleting from database
|
||||
deprovisionUserEnvironment(target.email, target.username ?? '');
|
||||
|
||||
await deleteUser(id);
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { resolve } from 'node:path';
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type {
|
||||
SidecarCommand,
|
||||
SidecarEvent,
|
||||
@@ -111,6 +113,13 @@ function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findSidecarByName(name: string): RegisteredSidecar | undefined {
|
||||
for (const sc of sidecars.values()) {
|
||||
if (sc.name === name) return sc;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Event dispatch ──
|
||||
|
||||
function dispatchEvent(msg: SidecarEvent | PtyEvent) {
|
||||
@@ -166,6 +175,105 @@ function sendFire(cap: string, cmd: SidecarCommand | PtyCommand): void {
|
||||
}
|
||||
}
|
||||
|
||||
function sendCommandToSidecar(
|
||||
sc: RegisteredSidecar,
|
||||
cmd: SidecarCommand | PtyCommand,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete((cmd as any).id);
|
||||
reject(new Error(`Sidecar command ${cmd.type} timed out`));
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set((cmd as any).id, { resolve, reject, timer });
|
||||
sc.ws.send(JSON.stringify(cmd));
|
||||
});
|
||||
}
|
||||
|
||||
function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand | PtyCommand): void {
|
||||
sc.ws.send(JSON.stringify(cmd));
|
||||
}
|
||||
|
||||
// ── On-demand Claude sidecar spawning ──
|
||||
|
||||
const USER_INSTANCE_SCRIPT = resolve(import.meta.dir, 'sidecar/claude/user-instance.ts');
|
||||
const SIDECAR_SPAWN_TIMEOUT_MS = 15_000;
|
||||
|
||||
const claudeProcs = new Map<string, Subprocess>();
|
||||
const claudeSpawnWaiters = new Map<string, Promise<RegisteredSidecar>>();
|
||||
|
||||
async function ensureClaudeSidecar(email: string): Promise<RegisteredSidecar> {
|
||||
const name = `claude:${email}`;
|
||||
|
||||
// Already registered?
|
||||
const existing = findSidecarByName(name);
|
||||
if (existing) return existing;
|
||||
|
||||
// Already spawning?
|
||||
const waiter = claudeSpawnWaiters.get(email);
|
||||
if (waiter) return waiter;
|
||||
|
||||
// Spawn and wait for registration
|
||||
const promise = spawnAndWaitForRegistration(email, name);
|
||||
claudeSpawnWaiters.set(email, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
claudeSpawnWaiters.delete(email);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAndWaitForRegistration(email: string, name: string): Promise<RegisteredSidecar> {
|
||||
// Get proxy secret for auth
|
||||
const proxySecret = await getProxySecret();
|
||||
const proxyPort = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
CLAUDE_USER_EMAIL: email,
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}`,
|
||||
ANTHROPIC_API_KEY: proxySecret,
|
||||
};
|
||||
|
||||
const proc = Bun.spawn(['bun', 'run', USER_INSTANCE_SCRIPT], {
|
||||
env,
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
|
||||
claudeProcs.set(email, proc);
|
||||
|
||||
// Clean up on exit
|
||||
proc.exited.then(() => {
|
||||
claudeProcs.delete(email);
|
||||
});
|
||||
|
||||
// Wait for the sidecar to register
|
||||
return new Promise<RegisteredSidecar>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsub();
|
||||
reject(new Error(`Claude sidecar for ${email} failed to register within ${SIDECAR_SPAWN_TIMEOUT_MS}ms`));
|
||||
}, SIDECAR_SPAWN_TIMEOUT_MS);
|
||||
|
||||
// Poll for registration (the sidecar connects via WebSocket and registerSidecar is called)
|
||||
const check = () => {
|
||||
const sc = findSidecarByName(name);
|
||||
if (sc) {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
resolve(sc);
|
||||
}
|
||||
};
|
||||
const interval = setInterval(check, 50);
|
||||
|
||||
const unsub = () => {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export function isConnected(): boolean {
|
||||
@@ -196,28 +304,41 @@ export function getProxySecretSync(): string {
|
||||
return cachedState?.proxySecret ?? '';
|
||||
}
|
||||
|
||||
// ── Claude Code ──
|
||||
// ── Claude Code (per-user routing) ──
|
||||
|
||||
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
||||
const res = await sendCommand('claude', { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
|
||||
const sc = await ensureClaudeSidecar(params.email);
|
||||
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
|
||||
if (res.type === 'claude:result') return res.result;
|
||||
if (res.type === 'claude:error') throw new Error(res.error);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
|
||||
const res = await sendCommand('claude', { type: 'claude:spawn-streaming', id: nextId(), params });
|
||||
const sc = await ensureClaudeSidecar(params.email);
|
||||
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn-streaming', id: nextId(), params });
|
||||
if (res.type === 'claude:spawned') return;
|
||||
if (res.type === 'claude:error') throw new Error(res.error);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export function killClaude(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey });
|
||||
export function killClaude(sessionKey: string, email: string): void {
|
||||
const sc = findSidecarByName(`claude:${email}`);
|
||||
if (sc) sendFireToSidecar(sc, { type: 'claude:kill', id: nextId(), sessionKey });
|
||||
}
|
||||
|
||||
export function clearClaudeSession(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
|
||||
export function clearClaudeSession(sessionKey: string, email?: string): void {
|
||||
if (email) {
|
||||
const sc = findSidecarByName(`claude:${email}`);
|
||||
if (sc) sendFireToSidecar(sc, { type: 'claude:clear-session', id: nextId(), sessionKey });
|
||||
} else {
|
||||
// Broadcast to all claude sidecars (used when email is not available)
|
||||
for (const sc of sidecars.values()) {
|
||||
if (sc.capabilities.includes('claude')) {
|
||||
sendFireToSidecar(sc, { type: 'claude:clear-session', id: nextId(), sessionKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onClaudeEvent(handler: (sessionKey: string, event: PiEvent) => void): () => void {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
|
||||
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
|
||||
import * as claudeManager from './claude-manager';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
@@ -9,7 +8,7 @@ const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '50
|
||||
// ── Startup ──
|
||||
|
||||
if (!acquireLock()) {
|
||||
console.error('[claude] another instance is already running (lock file exists with live PID)');
|
||||
console.error('[proxy] another instance is already running (lock file exists with live PID)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -20,7 +19,7 @@ ensureProxySecret();
|
||||
try {
|
||||
startAnthropicProxy();
|
||||
} catch (err) {
|
||||
console.error('[claude] failed to start Anthropic proxy:', err instanceof Error ? err.message : err);
|
||||
console.error('[proxy] failed to start Anthropic proxy:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// ── Command handlers ──
|
||||
@@ -48,43 +47,6 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
reply({ type: 'proxy:secret', id: cmd.id, secret: getProxySecret() });
|
||||
break;
|
||||
|
||||
case 'claude:spawn': {
|
||||
try {
|
||||
const result = await claudeManager.spawnClaude(cmd.params);
|
||||
reply({ type: 'claude:result', id: cmd.id, result });
|
||||
} catch (err) {
|
||||
reply({ type: 'claude:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:spawn-streaming': {
|
||||
reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
|
||||
|
||||
const onEvent = (event: import('../../api/pi/types').PiEvent) => {
|
||||
connection.send({ type: 'claude:event', sessionKey: cmd.params.sessionKey, event });
|
||||
};
|
||||
|
||||
claudeManager.spawnClaudeStreaming(cmd.params, onEvent).catch((err) => {
|
||||
connection.send({
|
||||
type: 'claude:event',
|
||||
sessionKey: cmd.params.sessionKey,
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:kill':
|
||||
claudeManager.killClaudeSession(cmd.sessionKey);
|
||||
reply({ type: 'claude:killed', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'claude:clear-session':
|
||||
claudeManager.clearSession(cmd.sessionKey);
|
||||
reply({ type: 'claude:session-cleared', id: cmd.id });
|
||||
break;
|
||||
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
@@ -98,8 +60,8 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'claude',
|
||||
capabilities: ['claude', 'proxy'],
|
||||
name: 'proxy',
|
||||
capabilities: ['proxy'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
@@ -108,7 +70,7 @@ const connection = createSidecarConnector({
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[claude] ${signal} received, saving state...`);
|
||||
console.log(`[proxy] ${signal} received, saving state...`);
|
||||
connection.destroy();
|
||||
await flushAndSave();
|
||||
releaseLock();
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
const STATE_DIR = join(DATA_PATH, 'sidecar');
|
||||
const STATE_FILE = join(STATE_DIR, 'claude-state.json');
|
||||
const LOCK_FILE = join(STATE_DIR, 'claude.lock');
|
||||
|
||||
export type PersistedState = {
|
||||
proxySecret: string;
|
||||
@@ -16,23 +13,42 @@ const DEFAULT_STATE: PersistedState = {
|
||||
claudeSessions: {},
|
||||
};
|
||||
|
||||
let stateDir: string;
|
||||
let stateFile: string;
|
||||
let lockFile: string;
|
||||
let currentState: PersistedState = { ...DEFAULT_STATE };
|
||||
let saveTimer: Timer | null = null;
|
||||
|
||||
/** Call once at startup to configure paths. For proxy: no email. For per-user: pass email. */
|
||||
export function initPaths(email?: string): void {
|
||||
if (email) {
|
||||
stateDir = join(DATA_PATH, email, 'sidecar');
|
||||
stateFile = join(stateDir, 'claude-state.json');
|
||||
lockFile = join(stateDir, 'claude.lock');
|
||||
} else {
|
||||
stateDir = join(DATA_PATH, 'sidecar');
|
||||
stateFile = join(stateDir, 'claude-state.json');
|
||||
lockFile = join(stateDir, 'claude.lock');
|
||||
}
|
||||
}
|
||||
|
||||
// Default to proxy paths
|
||||
initPaths();
|
||||
|
||||
function ensureDir() {
|
||||
if (!existsSync(STATE_DIR)) {
|
||||
mkdirSync(STATE_DIR, { recursive: true });
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function loadState(): PersistedState {
|
||||
ensureDir();
|
||||
try {
|
||||
if (!existsSync(STATE_FILE)) {
|
||||
if (!existsSync(stateFile)) {
|
||||
currentState = { ...DEFAULT_STATE };
|
||||
return currentState;
|
||||
}
|
||||
const text = require('node:fs').readFileSync(STATE_FILE, 'utf-8');
|
||||
const text = readFileSync(stateFile, 'utf-8');
|
||||
currentState = { ...DEFAULT_STATE, ...JSON.parse(text) };
|
||||
return currentState;
|
||||
} catch {
|
||||
@@ -43,7 +59,7 @@ export function loadState(): PersistedState {
|
||||
|
||||
export async function saveState(): Promise<void> {
|
||||
ensureDir();
|
||||
await Bun.write(STATE_FILE, JSON.stringify(currentState, null, 2));
|
||||
await Bun.write(stateFile, JSON.stringify(currentState, null, 2));
|
||||
}
|
||||
|
||||
export function getState(): PersistedState {
|
||||
@@ -90,14 +106,14 @@ export async function flushAndSave(): Promise<void> {
|
||||
export function acquireLock(): boolean {
|
||||
ensureDir();
|
||||
try {
|
||||
if (existsSync(LOCK_FILE)) {
|
||||
const pidStr = require('node:fs').readFileSync(LOCK_FILE, 'utf-8').trim();
|
||||
if (existsSync(lockFile)) {
|
||||
const pidStr = readFileSync(lockFile, 'utf-8').trim();
|
||||
const pid = Number(pidStr);
|
||||
if (pid && isProcessAlive(pid)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
require('node:fs').writeFileSync(LOCK_FILE, String(process.pid));
|
||||
writeFileSync(lockFile, String(process.pid));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -106,8 +122,8 @@ export function acquireLock(): boolean {
|
||||
|
||||
export function releaseLock(): void {
|
||||
try {
|
||||
if (existsSync(LOCK_FILE)) {
|
||||
require('node:fs').unlinkSync(LOCK_FILE);
|
||||
if (existsSync(lockFile)) {
|
||||
unlinkSync(lockFile);
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { VncStartParams, VncSessionInfo } from '../protocol';
|
||||
import { getHomeDirForRole, toShellUsername } from '@@/data-path';
|
||||
import { getHomeDirForRole } from '@@/data-path';
|
||||
|
||||
type VncSession = {
|
||||
email: string;
|
||||
@@ -87,7 +87,6 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
|
||||
sessions.delete(params.email);
|
||||
}
|
||||
|
||||
const shellUsername = toShellUsername(params.username, params.email);
|
||||
const homeDir = getHomeDirForRole(params.email, params.role);
|
||||
const resolution = params.resolution ?? '1920x1080';
|
||||
const display = findFreeDisplay();
|
||||
@@ -96,21 +95,9 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
|
||||
// Lazy-provision VNC environment if missing
|
||||
await ensureVncEnv(homeDir);
|
||||
|
||||
// Spawn VNC server as the target user
|
||||
// Spawn VNC server
|
||||
const proc = Bun.spawn({
|
||||
cmd: [
|
||||
'sudo',
|
||||
'-u',
|
||||
shellUsername,
|
||||
'vncserver',
|
||||
`:${display}`,
|
||||
'-geometry',
|
||||
resolution,
|
||||
'-depth',
|
||||
'24',
|
||||
'-localhost',
|
||||
'yes',
|
||||
],
|
||||
cmd: ['vncserver', `:${display}`, '-geometry', resolution, '-depth', '24', '-localhost', 'yes'],
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
@@ -130,7 +117,7 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
|
||||
pid = parseInt(content.trim(), 10) || 0;
|
||||
}
|
||||
|
||||
sessions.set(params.email, { email: params.email, username: params.username, display, port, pid });
|
||||
sessions.set(params.email, { email: params.email, username: params.username ?? '', display, port, pid });
|
||||
console.log(`[vnc] started session for ${params.email} on :${display} (port ${port}, pid ${pid})`);
|
||||
|
||||
return { port, display };
|
||||
@@ -140,11 +127,10 @@ export function stopSession(email: string): void {
|
||||
const session = sessions.get(email);
|
||||
if (!session) return;
|
||||
|
||||
const shellUsername = toShellUsername(session.username, email);
|
||||
const homeDir = getHomeDirForRole(email, null);
|
||||
|
||||
Bun.spawnSync({
|
||||
cmd: ['sudo', '-u', shellUsername, 'vncserver', '-kill', `:${session.display}`],
|
||||
cmd: ['vncserver', '-kill', `:${session.display}`],
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||
|
||||
const SEED_EXTENSIONS_DIR = join(SEED_PATH, 'extensions');
|
||||
const GLOBAL_EXTENSIONS_DIR = join(DATA_PATH, 'extensions');
|
||||
|
||||
export function syncSeedExtensions(): void {
|
||||
if (!existsSync(SEED_EXTENSIONS_DIR)) return;
|
||||
|
||||
mkdirSync(GLOBAL_EXTENSIONS_DIR, { recursive: true });
|
||||
|
||||
const seedEntries = readdirSync(SEED_EXTENSIONS_DIR, { withFileTypes: true });
|
||||
|
||||
for (const entry of seedEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const seedExtensionDir = join(SEED_EXTENSIONS_DIR, entry.name);
|
||||
const entryFile = join(seedExtensionDir, 'index.ts');
|
||||
if (!existsSync(entryFile)) continue;
|
||||
|
||||
const targetDir = join(GLOBAL_EXTENSIONS_DIR, entry.name);
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
// Extension already exists in DATA_PATH — always overwrite (managed by us, not the user)
|
||||
cpSync(seedExtensionDir, targetDir, { recursive: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
cpSync(seedExtensionDir, targetDir, { recursive: true });
|
||||
console.log(`[extensions] Synced seed extension: ${entry.name}`);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||
import { parseSeedVersion } from './sync-version';
|
||||
|
||||
const SEED_PROCESSES_DIR = join(SEED_PATH, 'processes');
|
||||
const GLOBAL_PROCESSES_DIR = join(DATA_PATH, 'processes');
|
||||
|
||||
export function syncSeedProcesses(): void {
|
||||
if (!existsSync(SEED_PROCESSES_DIR)) return;
|
||||
|
||||
mkdirSync(GLOBAL_PROCESSES_DIR, { recursive: true });
|
||||
|
||||
const seedEntries = readdirSync(SEED_PROCESSES_DIR, { withFileTypes: true });
|
||||
|
||||
for (const entry of seedEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const seedProcessDir = join(SEED_PROCESSES_DIR, entry.name);
|
||||
const seedFile = join(seedProcessDir, 'PROCESS.md');
|
||||
if (!existsSync(seedFile)) continue;
|
||||
|
||||
const targetDir = join(GLOBAL_PROCESSES_DIR, entry.name);
|
||||
const targetFile = join(targetDir, 'PROCESS.md');
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
|
||||
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
|
||||
if (seedVersion <= targetVersion) continue;
|
||||
rmSync(targetDir, { recursive: true });
|
||||
console.log(`[processes] Updating seed process: ${entry.name} (v${targetVersion} → v${seedVersion})`);
|
||||
}
|
||||
|
||||
cpSync(seedProcessDir, targetDir, { recursive: true });
|
||||
console.log(`[processes] Synced seed process: ${entry.name}`);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||
import { parseSeedVersion } from './sync-version';
|
||||
|
||||
const SEED_SKILLS_DIR = join(SEED_PATH, 'skills');
|
||||
const GLOBAL_SKILLS_DIR = join(DATA_PATH, 'skills');
|
||||
|
||||
export function syncSeedSkills(): void {
|
||||
if (!existsSync(SEED_SKILLS_DIR)) return;
|
||||
|
||||
mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true });
|
||||
|
||||
const seedEntries = readdirSync(SEED_SKILLS_DIR, { withFileTypes: true });
|
||||
|
||||
for (const entry of seedEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const seedSkillDir = join(SEED_SKILLS_DIR, entry.name);
|
||||
const seedFile = join(seedSkillDir, 'SKILL.md');
|
||||
if (!existsSync(seedFile)) continue;
|
||||
|
||||
const targetDir = join(GLOBAL_SKILLS_DIR, entry.name);
|
||||
const targetFile = join(targetDir, 'SKILL.md');
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
|
||||
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
|
||||
if (seedVersion <= targetVersion) continue;
|
||||
rmSync(targetDir, { recursive: true });
|
||||
console.log(`[skills] Updating seed skill: ${entry.name} (v${targetVersion} → v${seedVersion})`);
|
||||
}
|
||||
|
||||
cpSync(seedSkillDir, targetDir, { recursive: true });
|
||||
console.log(`[skills] Synced seed skill: ${entry.name}`);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||
import { parseSeedVersion } from './sync-version';
|
||||
|
||||
const SEED_TASKS_DIR = join(SEED_PATH, 'tasks');
|
||||
const GLOBAL_TASKS_DIR = join(DATA_PATH, 'tasks');
|
||||
|
||||
export function syncSeedTasks(): void {
|
||||
if (!existsSync(SEED_TASKS_DIR)) return;
|
||||
|
||||
mkdirSync(GLOBAL_TASKS_DIR, { recursive: true });
|
||||
|
||||
const seedEntries = readdirSync(SEED_TASKS_DIR, { withFileTypes: true });
|
||||
|
||||
for (const entry of seedEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const seedTaskDir = join(SEED_TASKS_DIR, entry.name);
|
||||
const seedFile = join(seedTaskDir, 'TASK.md');
|
||||
if (!existsSync(seedFile)) continue;
|
||||
|
||||
const targetDir = join(GLOBAL_TASKS_DIR, entry.name);
|
||||
const targetFile = join(targetDir, 'TASK.md');
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
|
||||
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
|
||||
if (seedVersion <= targetVersion) continue;
|
||||
rmSync(targetDir, { recursive: true });
|
||||
console.log(`[tasks] Updating seed task: ${entry.name} (v${targetVersion} → v${seedVersion})`);
|
||||
}
|
||||
|
||||
cpSync(seedTaskDir, targetDir, { recursive: true });
|
||||
console.log(`[tasks] Synced seed task: ${entry.name}`);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||
import { parseSeedVersion } from './sync-version';
|
||||
|
||||
const SEED_TOOLS_DIR = join(SEED_PATH, 'tools');
|
||||
const GLOBAL_TOOLS_DIR = join(DATA_PATH, 'tools');
|
||||
|
||||
export function syncSeedTools(): void {
|
||||
if (!existsSync(SEED_TOOLS_DIR)) return;
|
||||
|
||||
mkdirSync(GLOBAL_TOOLS_DIR, { recursive: true });
|
||||
|
||||
const seedEntries = readdirSync(SEED_TOOLS_DIR, { withFileTypes: true });
|
||||
|
||||
for (const entry of seedEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const seedToolDir = join(SEED_TOOLS_DIR, entry.name);
|
||||
const seedFile = join(seedToolDir, 'TOOL.md');
|
||||
if (!existsSync(seedFile)) continue;
|
||||
|
||||
const targetDir = join(GLOBAL_TOOLS_DIR, entry.name);
|
||||
const targetFile = join(targetDir, 'TOOL.md');
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
|
||||
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
|
||||
if (seedVersion <= targetVersion) continue;
|
||||
rmSync(targetDir, { recursive: true });
|
||||
console.log(`[tools] Updating seed tool: ${entry.name} (v${targetVersion} → v${seedVersion})`);
|
||||
}
|
||||
|
||||
cpSync(seedToolDir, targetDir, { recursive: true });
|
||||
console.log(`[tools] Synced seed tool: ${entry.name}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user